ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

卷积自编码器与K-means聚类:无监督指纹图像识别实战指南

卷积自编码器与K-means聚类:无监督指纹图像识别实战指南 简介这份zip压缩包约9.71MB汇集了武汉理工大学2020年数学建模暑期培训的论文与代码主题为基于卷积自编码的指纹编码与k聚类模型。内容面向数学建模竞赛参与者、机器学习初学者及生物识别方向研究者提供了一套从指纹图像预处理、卷积自编码特征提取到k-均值聚类分类的完整实现方案。论文部分详细阐述了CAE如何将高维指纹图像转化为紧凑编码以及如何通过k-Means聚类提高分类精度代码部分则便于读者复现实验并在此基础上扩展。由于平台暂未提供文件总数与类型明细具体构成可下载后查看但结合研究主题包内应包含文档、源码及必要的数据说明。已有60人学习适合希望借鉴深度学习与传统聚类结合思路的读者使用。1. 卷积自编码加K-means为什么数学建模题总用这组合指纹图像天然适合“无监督 聚类”这条路数据量大但标注成本极高随便拿一批指纹图给你哪张对应哪根手指往往是未知的。直接对原始像素做K-means聚类结果通常惨不忍睹——像素维度太高光照、灰度、背景纹理一掺和聚类边界被噪声撕得稀碎。于是数学建模暑期培训里常见的解法是先让卷积自编码器把每张指纹压成一个低维向量再用K-means对这组向量聚类。这套“无监督特征提取 无监督聚类”组合解决的正是“没有标签又想分类”的场景适合做生物特征分析、图像检索、异常检测也是当年很多参赛论文的骨架。我用这套流程处理过类似图像集先说结论模型本身不复杂复杂的是预处理、特征归一化和聚类K值的选择。下面按一条完整可复现的路线讲从网络结构拆到参数设置再把踩过的坑一个个列出来。2. 从原始指纹图像到特征向量卷积自编码器的结构与参数拆解2.1 为什么编码要用卷积而不是全连接自编码器的任务是把输入压成浓缩表示再从浓缩表示还原输入。如果中间层用全连接指纹图像先被拉成一维向量每个像素和自己的位置关系就彻底丢掉了。指纹的纹路是局部连续的——谷线、脊线有方向性相邻像素的灰度有强关联。全连接层感知不了这种局部模式它只按像素坐标学权重换个位置纹路就“不认识”了。卷积层则天然匹配图像结构。每个卷积核只覆盖一个小感受野比如3×3或5×5通过滑动窗口提取局部纹理特征。权值共享让同一个卷积核在不同位置复用参数量比全连接少几个数量级。例如输入128×128×1的图像第一层用32个3×3卷积核参数只有(3×3×11)×32 320个如果用全连接层把16384个像素接到512个神经元单层参数就是800多万。参数量少训练时不容易过拟合也更适合数学建模比赛里那种“几千张图、没有预训练权重”的起步条件。除了降维卷积自编码器的另一个价值是特征逐步抽象。浅层卷积学的是灰度边缘中层学纹路方向深层学指纹的全局结构。这样得到的编码向量不是简单的像素压缩而是对指纹本质结构的提炼。我一般会把编码器的最后一层特征向量维度控制在64到128之间既能保留分类所需信息又不会让后面的K-means在高维空间里遇到“维度灾难”。2.2 最小可跑通的自编码器定义Keras下面这份代码是典型的卷积自编码器结构输入128×128灰度图输出重建图和编码向量同时可用。用Keras的Functional API写比Sequential更灵活因为你最终要单独取出编码器做特征提取。import tensorflow as tf from tensorflow.keras import layers, Model def build_conv_autoencoder(latent_dim64): # ---- 编码器 ---- encoder_input layers.Input(shape(128, 128, 1), namefingerprint_input) x layers.Conv2D(32, (3, 3), strides(2, 2), paddingsame, activationrelu)(encoder_input) x layers.Conv2D(64, (3, 3), strides(2, 2), paddingsame, activationrelu)(x) x layers.Conv2D(128, (3, 3), strides(2, 2), paddingsame, activationrelu)(x) # 此时特征图尺寸为 16 x 16 x 128 x layers.Flatten()(x) feature layers.Dense(latent_dim, activationlinear, namefeature_vector)(x) encoder Model(encoder_input, feature, nameencoder) # ---- 解码器 ---- decoder_input layers.Input(shape(latent_dim,), namelatent_input) x layers.Dense(16 * 16 * 128, activationrelu)(decoder_input) x layers.Reshape((16, 16, 128))(x) x layers.Conv2DTranspose(128, (3, 3), strides(2, 2), paddingsame, activationrelu)(x) x layers.Conv2DTranspose(64, (3, 3), strides(2, 2), paddingsame, activationrelu)(x) x layers.Conv2DTranspose(32, (3, 3), strides(2, 2), paddingsame, activationrelu)(x) decoder_output layers.Conv2D(1, (3, 3), paddingsame, activationsigmoid)(x) decoder Model(decoder_input, decoder_output, namedecoder) # ---- 拼成完整自编码器 ---- autoencoder_input encoder_input autoencoder_output decoder(encoder(encoder_input)) autoencoder Model(autoencoder_input, autoencoder_output, nameconv_autoencoder) return autoencoder, encoder, decoder逻辑说明编码器里三个Conv2D层都用strides(2,2)下采样所以输入从128×128依次变成64×64、32×32、16×16。通道数从32涨到128语义信息越来越抽象空间分辨率越来越低。最后Flatten再接一个全连接层得到latent_dim维的向量。解码器逆过程先用Dense把向量拉回16×16×128再通过三个Conv2DTranspose层逐步上采样回128×128。最后一层用sigmoid因为输入灰度值归一化到0到1之间输出需要落在同一区间。参数说明latent_dim64是经验值太小编码器会丢细节重建图糊成一片太大又会让K-means在后续聚类时距离差异不明显。建议从32试到128观察重建图像和聚类得分。卷积核大小固定为3×3这是图像任务里性价比最高的选择5×5感受野更大但参数量多一倍在小数据集上不容易收敛。训练部分的代码如下autoencoder.compile( optimizertf.keras.optimizers.Adam(learning_rate1e-4), lossmse ) history autoencoder.fit( train_images, train_images, batch_size32, epochs50, validation_data(val_images, val_images), callbacks[tf.keras.callbacks.EarlyStopping(patience5)] )逻辑说明这里重建目标就是输入本身所以fit的x和y都是train_images。损失用MSE逐像素比较值越小重建图和原图越接近。Adam学习率设1e-4是保守选择指纹图像细节多学习率太大会让重建损失震荡。EarlyStopping在验证损失连续5轮不降时自动停避免浪费时间。2.3 训练策略与重建质量判断训练过程中不要只看loss数值更直观的做法是每隔几个epoch把重建图与原图并排展示。我一般会用matplotlib生成一张对比图第一行是原图第二行是重建图。如果重建图保持了纹路骨架但细节略糊说明编码向量保留的是关键结构如果连纹路方向都对不上说明网络太浅或潜变量维度太低。损失函数除了MSE也可以用SSIM。SSIM更关注结构相似性和人眼判断接近。但在数学建模场景里MSE足够而且梯度计算简单不容易在自定义损失函数上翻车。如果你想让编码器学到的特征更“稠密”可以在MSE基础上加上一个对编码向量的L2正则项比如tf.keras.regularizers.l2(1e-5)防止某些维度数值过大主导聚类距离。一个容易被忽略的细节训练自编码器时要不要数据增强指纹图像偏移几个像素重建目标也偏移了网络会尝试学习这种平移不变性这其实对编码特征有好处。但要注意验证集不能用同样的增强方式否则验证损失不可信。3. 指纹编码的落地流程图像预处理与特征提取的完整流水线3.1 指纹图像预处理灰度化、裁剪与尺寸归一原始指纹图像不会恰好是128×128有的带白边有的亮度不均。如果直接resize指纹纹路会被拉伸变形。我常用的处理顺序是转灰度图、裁剪中心区域、再统一尺寸。裁剪能去掉采集设备边缘的光晕和背景噪声。裁剪比例根据图像分辨率定比如原图是512×512我取中央300×300再resize到128×128这样既保留核心纹路又减少背景干扰。import cv2 import numpy as np def load_fingerprint(path, target_size(128, 128), crop_ratio0.6): # 读取为灰度图 img cv2.imread(path, cv2.IMREAD_GRAYSCALE) if img is None: raise ValueError(fcannot read image: {path}) # 中心裁剪 h, w img.shape[:2] ch, cw int(h * crop_ratio), int(w * crop_ratio) sh, sw (h - ch) // 2, (w - cw) // 2 img img[sh:sh ch, sw:sw cw] # 尺寸归一化缩小用 INTER_AREA保留纹路更稳 img cv2.resize(img, target_size, interpolationcv2.INTER_AREA) # 归一化到 0~1 img img.astype(float32) / 255.0 # 增加通道维度(128, 128) - (128, 128, 1) img np.expand_dims(img, axis-1) return img逻辑说明crop_ratio0.6表示只保留原图中心60%的区域。指纹采集时手指位置通常在中心边缘往往是背景或污渍。INTER_AREA是缩小图片时最合适的插值方式它能平均周围像素抑制高频噪声如果换成INTER_LINEAR纹路边缘容易出现锯齿编码器会把锯齿当特征学进去。参数说明target_size(128,128)不是固定的。如果你的GPU内存紧张可以用96×96减少自编码器的计算量。但不要低于64×64否则指纹脊线之间的间隔可能小于一个像素细纹路直接消失。crop_ratio需要根据实际图像调节如果指纹本身就占满全图强行裁剪会切掉有效纹路这种情况就把crop_ratio调成1.0或0.9。3.2 用训练好的编码器提取特征向量训练完成后你要提取的是编码器输出的feature向量而不是重建图。以我们定义的encoder为例输入一批图像输出形状是(batch_size, latent_dim)。这里有一个常见误解有人会把自编码器中间层的隐含表示直接拼上重建误差当作特征这也可以但聚类时特征维度会更高更容易受噪声影响。我建议就只用编码器输出。def extract_features(encoder, image_paths, batch_size64): features [] for i in range(0, len(image_paths), batch_size): batch_paths image_paths[i:i batch_size] batch_images np.array([load_fingerprint(p) for p in batch_paths]) # encoder.predict 返回每个样本的 latent vector batch_feat encoder.predict(batch_images, verbose0) features.append(batch_feat) features np.vstack(features) # 形状: (样本数, latent_dim) return features逻辑说明这里分批predict避免一次性把所有图像载入内存。每批的batch_size和训练时保持一致或稍大predict阶段不计算梯度所以可以开更大batch。np.vstack把每批结果纵向拼接成一个大矩阵每一行是一张指纹的编码向量。参数说明batch_size64不是硬性要求但要注意predict时如果显存不足会出现OOM错误这时可以调到32或16。如果你用的是CPU跑大batch反而会让predict变慢因为内存复制开销增加。3.3 特征向量要不要归一化L2 范数为什么优先从编码器拿到的feature向量每个维度的数值范围可能完全不同。有的维度集中在-0.2到0.2有的则在-5到5。如果直接做K-means欧氏距离会被值域大的维度主导值域小的有效特征被淹没。我一般先做L2归一化让每个向量的范数变成1这样所有样本落在同一超球面上距离只由方向决定与光照、对比度等线性缩放因素无关。def l2_normalize(features, eps1e-8): norms np.linalg.norm(features, axis1, keepdimsTrue) return features / (norms eps)逻辑说明norms是每个样本的L2范数keepdimsTrue保持二维形状才能广播相除。加eps防止某个向量全零时除零。归一化之后任意两个样本之间的欧氏距离和余弦距离的关系变成d_euclidean^2 2 - 2 * cos_sim所以K-means用欧氏距离聚类等价于比较指纹编码的方向相似度。参数说明eps一般取1e-8就行太小没意义太大会把向量向0方向拉伸破坏距离比例。如果特征向量里可能有大面积的零也可以用StandardScaler先行标准化再做L2归一化但我不建议前置标准化因为标准化是按维度进行的会放大噪声维度的作用。4. 把特征交给K-means聚类个数、初始化与距离度量4.1 用 sklearn 的 KMeans 跑指纹聚类编码阶段完成后喂给K-means的输入是一个干净矩阵行是样本列是特征维度。下面代码直接跑聚类并输出每个样本的簇标签。from sklearn.cluster import KMeans n_clusters 5 # 假设指纹对应5个类别 kmeans KMeans( n_clustersn_clusters, initk-means, n_init10, max_iter300, random_state42 ) cluster_labels kmeans.fit_predict(features_l2)逻辑说明fit_predict一步完成训练并返回标签等价于先fit再predict。k-means初始化让初始中心尽量分散避免空簇n_init10表示用10组不同初始中心运行10次选惯性最小的结果。指纹特征维度不高10次运行的计算开销完全可以接受。参数说明n_clusters是聚类数这个必须自己给。如果你连“大约几类”都不确定按下面2.2的肘部法先试探。random_state42固定随机种子让结果可复现。数学建模比赛和论文里复现性比最大的聚类纯度更重要固定种子是必须的。max_iter300是单次运行的最大迭代轮数K-means一般在几十轮内收敛300足够。4.2 聚类个数 K肘部法只能看趋势轮廓系数才靠谱确定K的值有两种常用手段肘部法和轮廓系数。肘部法看的是簇内误差平方和inertiaK增大时inertia必然减小但下降速度会变缓拐点即“肘部”。问题在于指纹特征并不是天然球状分布肘部经常不明显。所以我会两个方法一起看拐点模糊时以轮廓系数为准。import numpy as np from sklearn.cluster import KMeans from sklearn.metrics import silhouette_score def evaluate_k(features, k_rangerange(2, 11)): inertia_list [] silhouette_list [] for k in k_range: km KMeans(n_clustersk, initk-means, n_init10, random_state42) km.fit(features) inertia_list.append(km.inertia_) sil silhouette_score(features, km.labels_) silhouette_list.append(sil) for k, inert, sil in zip(k_range, inertia_list, silhouette_list): print(fK{k}: inertia{inert:.2f}, silhouette{sil:.4f}) return k_range, inertia_list, silhouette_list逻辑说明inertia是每个样本到其所属簇中心的距离平方和越小说明簇内越紧凑。silhouette_score计算每个样本的轮廓系数取值在-1到1之间越接近1说明样本与自身簇的距离远小于与最近簇的距离聚类越合理。参数说明k_rangerange(2,11)覆盖2到10类。如果你的任务明确知道是5类可以缩小范围到4到6节省时间。轮廓系数是全局平均它偏爱凸形簇对指纹这种高维特征不绝对可靠所以还要结合下一节的带标签验证。4.3 聚类效果怎么评分纯度、RI 与带标签小样本验证无监督聚类没有“准确率”一说但如果你手里有一小批带标签的样本比如培训资料里给了20张图对应哪根手指就能用这些标签做验证。最直观的指标是纯度purity每个簇里最多的真实类别占比的加权平均。from sklearn.metrics import adjusted_rand_score def clustering_purity(cluster_labels, true_labels): # 每个簇中统计真实标签的分布 unique_clusters np.unique(cluster_labels) total_correct 0 for c in unique_clusters: mask (cluster_labels c) true_in_cluster true_labels[mask] # 取该簇中出现最多的真实标签数量 max_count np.bincount(true_in_cluster).max() total_correct max_count return total_correct / len(cluster_labels) # 示例cluster_labels是K-means结果true_labels是少量样本的已知标签 pur clustering_purity(cluster_labels, true_labels) ari adjusted_rand_score(true_labels, cluster_labels) print(fpurity{pur:.4f}, ARI{ari:.4f})逻辑说明纯度计算时不关心簇编号和真实标签的对应关系只看每个簇内是否“单一”。调整兰德指数ARI则衡量两种划分的一致性并修正了随机划分的影响比纯度更客观。如果ARI接近0说明聚类结果和真实类别几乎无关接近1才是真正的吻合。参数说明这里true_labels必须是整数编码从0开始否则np.bincount会跳过某些索引。如果真实标签是字符串先做LabelEncoder。纯度和ARI只是验证手段不能用来回改聚类标签因为它们会泄露真实类别的分布信息。数学建模论文里通常用少量样本展示聚类效果再用全部无标签样本训练模型。5. 避坑指南指纹编码与聚类中的5个常见翻车点5.1 指纹光照不均自编码器学的不是纹路而是亮度分布现象重建图像看起来很清晰但编码向量聚类后同一个手指的指纹被分到好几个簇不同手指的反而聚在一起。原因预处理只做了灰度化和resize没有去掉光照梯度。指纹采集时中心亮、边缘暗是很常见的自编码器作为最省力的重构模型优先学会重建这种大面积亮度变化因为MSE损失里亮度差异的权重远大于纹路细节。编码向量里前几维几乎全是亮度信息纹路特征被挤到很低的权重上。解决在预处理里增加高通滤波或背景减除。我常用cv2.GaussianBlur提取低频背景然后原图减去背景突出纹路。也可以在训练自编码器时对输入做随机对比度和亮度扰动让网络明白这些变化不是需要保留的本质特征。调整后重建图的背景应该接近均匀灰色而不是接近原图的光照。5.2 特征维度尺度差异大K-means距离被大数值维度绑架现象聚类轮廓系数很低甚至出现一个簇里只有一两个样本的极端情况。原因编码器输出的特征向量不同维度数值范围不同某些维度因为训练不充分始终偏大欧氏距离计算时它们说了算。这本质上不是聚类算法的问题而是输入特征没有归一化。解决按第3.3节的代码做L2归一化归一化后每个样本的范数相等距离主要由方向决定。如果你发现归一化后轮廓系数还是低再尝试StandardScaler标准化每一维度到均值为0方差为1但要注意标准化会放大噪声维度最好结合PCA降维。我的习惯是先L2归一化再接一个PCA保留95%方差最后才喂给K-means。5.3 潜变量维度设错重建图糊或特征冗余现象潜变量维度设成8重建图像纹路连成一片设成512聚类反而更差且训练时间暴增。原因潜变量维度太低自编码器被迫丢弃太多信息特征不够用于区分不同指纹潜变量维度太高网络有足够容量记住训练集但泛化差编码向量中大量维度是噪声K-means在这些无关维度上计算距离反而干扰真实结构。解决先试latent_dim 64训练完后看重建图的纹路清晰度。然后在32、64、128三档之间比较聚类得分。一个经验值是看特征矩阵的特征值衰减曲线如果前20个特征值占了总方差的90%64维可能都偏高如果前50个只占70%就说明需要更多维度。这个分析可以用np.linalg.svd快速完成比盲调更省时间。5.4 K值选择不当肘部法看不到明显拐点现象evaluate_k打印出inertia后曲线平滑下降找不到哪里是“肘”但你心里又清楚大概有5类。原因指纹编码向量的分布不是标准的高斯混合K-means的损失函数是非凸的不同K值之间的inertia差距不够大。肘部法在这种数据集上失灵很常见不是你的预处理出了错。解决把肘部法结果和轮廓系数放一起看选择轮廓系数最高的K。同时可以做一个层次聚类的树状图直观观察样本合并路径确定一个更可信的K。如果业务场景给了约束比如已知只有5个人录入指纹那K就固定为5不要被数据结果带偏。数学建模里“领域知识”有时比统计指标更可靠。5.5 训练和推理预处理不一致模型在训练时表现好聚类时却崩了现象自编码器训练结束重建损失很低但提取特征做聚类结果毫无规律。排查半天发现训练时图像做了中心裁剪提取特征时直接resize了原图。原因训练和推理用了两条预处理流水线。自编码器学到的特征分布是基于训练时的输入分布推理时输入分布变了编码器输出立即漂移。这个问题隐藏很深因为模型不会报错只会默默给出错误特征。解决把预处理统一封装成一个函数训练和推理都调用同一个load_fingerprint。在训练脚本里保存预处理参数——包括裁剪比例、resize尺寸、归一化方式——到JSON文件推理时读取该文件。同时做一个冒烟测试取一张训练图走完整流水线检查输出形状和数值范围是否与训练数据一致。这条经验适用于任何“先降维后聚类”的流程不只是指纹。6. 从复现到改进用 t-SNE 和少量标签把模型调到可信基本流程跑通后我强烈建议你加一步t-SNE可视化。聚类结果是一堆簇编号缺少空间感把编码向量降到二维用散点图画出样本分布能一眼看出簇间是否分离、簇内是否紧凑。下面是常见做法from sklearn.manifold import TSNE tsne TSNE(n_components2, perplexity30, random_state42) features_tsne tsne.fit_transform(features_l2) plt.figure(figsize(8, 6)) scatter plt.scatter(features_tsne[:, 0], features_tsne[:, 1], ccluster_labels, cmaptab10, s8) plt.colorbar(scatter) plt.title(t-SNE of fingerprint latent vectors) plt.savefig(tsne_clusters.png, dpi150)perplexity一般设在5到50之间样本量小于100时用5否则用30。如果散点图上不同簇完全重叠即便K-means的轮廓系数高也说明特征区分度不足需要回头调自编码器的层数或潜变量维度。我的最后一个习惯是留出10%带标签的样本不参与训练专门用来做最终验证计算聚类纯度和ARI。当ARI能稳定超过0.8时模型才真正可交付。曾经我在一个类似的数据集上以为K-means聚类效果很好结果t-SNE一画才发现——它把光照相似的样本聚在了一起和指纹类别毫无关系。准备好前置规范化的流程之后这类问题就再也没坑过我。希望帮到你。本文还有配套的精品资源点击获取
返回列表