Siamese网络图像相似性比对实战:小样本下的度量学习

发布时间:2026/6/18 19:38:30

Siamese网络图像相似性比对实战:小样本下的度量学习 1. 项目概述为什么我坚持用 Siamese 网络做图像比对而不是直接分类你有没有遇到过这种场景手头只有几十张某类设备的故障照片但需要快速判断新拍的一张图是否和已知故障属于同一类型或者你正在开发一个内部文档管理系统用户上传一份扫描件系统得立刻回答“这和历史档案库里哪几份最相似”——这时候传统分类模型就卡壳了。它要求你提前定义好所有可能的类别可现实里新类别随时在冒出来它还要求每个类别下有足够多的样本去“喂饱”模型而你手里的数据往往连“小样本”都算不上更接近“极小样本”。Siamese 网络就是为这种“活儿”生的。它不关心“这是什么”只专注回答一个更底层、更普适的问题“这两个东西像不像” 它把图像映射到一个高维空间里让同类图像彼此靠近异类图像彼此远离。最终输出的那个数字不是类别标签而是它们在特征空间里的“距离”。这个设计思路让它天然具备小样本适应性、跨类别泛化能力以及对数据分布变化的鲁棒性。我第一次在产线质检项目里用它识别新型号螺丝的微小划痕时只用了不到200张图准确率就稳在94%以上。后来在医疗影像辅助筛查中用它比对同一患者不同时期的CT切片找早期病灶变化效果也远超预期。它不是万能锤但当你面对的是“相似性判断”这个核心需求时它往往是那个最省力、最可靠、最容易落地的工具。本文就以 MNIST 手写数字为例手把手带你从零构建一个真正能跑起来、能调优、能复用的 Keras Siamese 模型所有代码逻辑、参数选择、避坑细节都来自我过去三年在六个不同项目中的实操沉淀。2. 核心设计与思路拆解为什么是孪生结构而不是双塔或对比学习2.1 孪生网络的本质共享权重的“镜像大脑”很多人初看 Siamese 网络会把它和“双塔模型”Two-Tower Model混淆。表面上看两者都有两个输入分支。但关键区别在于权重是否共享。双塔模型的左右两个塔通常是完全独立训练的比如一个塔处理用户画像一个塔处理商品特征最后再做点积。而 Siamese 网络的左右两个分支必须、强制、完全共享所有权重参数。这意味着无论你喂给左边的是数字“3”的图片还是右边的是数字“7”的图片它们经过的是同一个卷积核、同一个全连接层、同一个 Dropout 掩码。这个设计不是为了偷懒而是有深刻的数学和工程考量。首先它保证了度量空间的一致性。如果左右分支权重不同那么“3”被映射到一个位置“7”被映射到另一个位置这个位置关系就失去了可比性。共享权重相当于给整个系统装上了一把统一的“尺子”确保所有图像都在同一个坐标系下被测量。其次它极大地降低了模型复杂度和过拟合风险。MNIST 数据集本身只有 6 万张图如果左右分支各有一套参数模型总参数量会翻倍而有效信息量并没有增加训练过程会变得极其脆弱。共享权重后模型参数量不变但学习目标从“预测单个标签”变成了“学习一个通用的特征提取器”这个目标更稳定也更容易收敛。我在一个工业缺陷检测项目里做过对照实验用相同结构的双塔模型非共享权重训练验证集准确率始终在 82% 上下波动且 loss 曲线剧烈震荡换成共享权重的 Siamese 结构后loss 平滑下降最终稳定在 95.3%训练时间还缩短了 37%。这个差距就是“一致性”带来的红利。2.2 对比学习Contrastive Learning与三元组损失Triplet Loss为什么本文选前者Siamese 网络的训练核心在于损失函数的设计。目前主流有两大流派基于成对样本的对比损失Contrastive Loss和基于三元组样本的三元组损失Triplet Loss。原文选择了前者我的实践也证明在绝大多数入门级和中等复杂度的图像比对任务中对比损失是更优解。对比损失的输入很简单一对图像x1, x2和一个标签 y。y1 表示这对图像是“正样本对”same classy0 表示“负样本对”different class。它的损失函数长这样L y * d² (1-y) * max(margin - d, 0)²其中 d 是两个图像特征向量的欧氏距离。这个公式背后的思想非常直观对于正样本对我们希望 d 越小越好所以直接惩罚 d²对于负样本对我们不强求它们离得多远只要保证 d 大于一个预设的“间隔”margin即可超出 margin 的部分不额外惩罚。这个 margin 参数就是我们人为设定的“相似性阈值”。它就像一把标尺决定了模型认为“多近才算像”。在 MNIST 任务里我通常把 margin 设为 1.0因为经过实验当距离小于 0.8 时模型判别准确率最高而 1.0 给了模型一个合理的缓冲区避免过拟合。而三元组损失需要输入三个样本一个锚点Anchor、一个正样本Positive和锚点同类、一个负样本Negative和锚点异类。它的目标是让锚点到正样本的距离比到负样本的距离至少小一个 margin。公式是L max(d(a,p) - d(a,n) margin, 0)。听起来更“精准”但问题在于采样难度。你需要在每一轮训练中主动去挖掘那些“难分”的三元组——即 d(a,p) 很大或者 d(a,n) 很小的组合。这需要复杂的在线难例挖掘Online Hard Example Mining策略否则模型很快就会在大量简单的三元组上“躺平”学不到真本事。我在一个高精度人脸识别项目里尝试过三元组损失光是调试采样策略就花了整整两周最终效果和精心调参的对比损失相差无几。所以除非你的任务对精度有极致要求且你有充足的算力和耐心去调优采样器否则从对比损失入手是更务实、更高效的选择。2.3 为什么用欧氏距离而不是余弦相似度在计算两个特征向量的“距离”时有两个最常用的选择欧氏距离Euclidean Distance和余弦相似度Cosine Similarity。原文用了前者我也强烈推荐。原因在于它们的物理意义和优化目标完全不同。欧氏距离衡量的是两个向量在空间中的绝对距离。它对向量的模长即特征的“强度”敏感。一个很强的特征向量和一个很弱的特征向量即使方向一致欧氏距离也会很大。这恰恰符合我们对“相似性”的直觉两张清晰的手写“3”它们的特征向量应该既方向一致模长也相近距离才小一张模糊的“3”和一张清晰的“3”虽然方向可能接近但模长差异大距离就应该大一些提醒我们这张图质量存疑。这种对模长的敏感性在实际应用中是个巨大的优势。比如在文档比对场景一张扫描件分辨率高、对比度好另一张是手机随手拍的、有阴影和反光欧氏距离能天然地反映出这种质量差异。而余弦相似度只关心两个向量的夹角完全忽略模长。它把所有向量都投影到单位球面上只比较方向。这在某些 NLP 任务中很有用因为词向量的模长往往没有明确的物理意义。但在图像领域特征向量的模长直接关联到图像内容的“显著性”和“置信度”。强行归一化反而会抹掉这些有价值的信息。我做过一个实验在同一个 Siamese 模型上分别用欧氏距离和余弦相似度作为输出其他所有条件不变。在 MNIST 测试集上欧氏距离的 top-1 准确率是 96.8%而余弦相似度只有 92.1%。差距看似不大但在一个需要 99% 可靠性的金融票据审核系统里这 4.7 个百分点的差距就意味着每天要多出几百张需要人工复核的票据。所以选择欧氏距离不是跟风而是基于对任务本质的深刻理解。3. 核心细节解析与实操要点数据配对、模型构建与损失函数实现3.1 数据配对如何生成高质量的正负样本对Siamese 网络的“燃料”是成对的样本。配对的质量直接决定了模型的上限。原文提到的create_pairs函数是一个很好的起点但要让它真正“扛打”还需要几个关键的增强技巧。首先正样本对的生成不能只靠“随机匹配同类”。在 MNIST 中这意味着把所有“0”的图片两两组合。但这样会产生大量“冗余对”。比如一张非常标准的“0”和另一张也非常标准的“0”它们的特征距离天然就很近模型学不到什么新东西。更好的做法是引入难度控制。我的标准流程是先对每个数字类别按某种指标如边缘清晰度、像素方差排序然后只取排序前 20% 和后 20% 的图片来组成正样本对。这样每一对都是“一个清晰版”和“一个模糊版”或者“一个标准版”和“一个变形版”迫使模型去学习那些更具判别性的、鲁棒的特征。在代码层面这只需要在create_pairs函数里加几行# 假设 X_train 是图像数据y_train 是标签 from sklearn.feature_extraction.image import extract_patches_2d import numpy as np def calculate_clarity_score(img): 计算图像清晰度得分简单用拉普拉斯方差 return cv2.Laplacian(img, cv2.CV_64F).var() # 对每个类别单独处理 for digit in range(10): digit_indices np.where(y_train digit)[0] digit_images X_train[digit_indices] # 计算每个图像的清晰度 clarity_scores np.array([calculate_clarity_score(img) for img in digit_images]) # 获取索引并按清晰度排序 sorted_indices digit_indices[np.argsort(clarity_scores)] # 取最清晰的20%和最模糊的20% n len(sorted_indices) top_20 sorted_indices[-int(0.2*n):] bottom_20 sorted_indices[:int(0.2*n)] # 在这两组之间进行配对 for i in top_20: for j in bottom_20: if i ! j: # 避免自配对 pairs.append([X_train[i], X_train[j]]) labels.append(1)其次负样本对的生成要避免“过于简单”。随机从不同类别里抓两个图比如一个“0”和一个“8”它们的形状天差地别模型一眼就能区分这种对就是“简单负样本”对训练帮助很小。我们需要的是“困难负样本”即那些视觉上容易混淆的类别对。在 MNIST 中最典型的困难对就是“3”和“8”、“5”和“6”、“7”和“1”。因此我修改了负样本生成逻辑不是完全随机而是预先定义一个“混淆矩阵”记录历史上哪些数字对最容易被误判然后按这个矩阵的概率分布来采样负样本。这能让模型把有限的训练资源集中在攻克最难啃的骨头。最后数据增强必须作用于配对后的样本。这是一个极易被忽视的陷阱。很多新手会先对原始图像做增强旋转、缩放、加噪然后再配对。这会导致一个问题同一个原始图像经过不同的增强会产生多个“变体”而这些变体之间又被当作正样本对。这会让模型学到的不是“数字本身的相似性”而是“某种特定噪声模式的相似性”。正确的做法是先生成原始的正负样本对然后对每一对中的两个图像分别、独立地施加相同的增强变换。例如对一对“3”和“3”我们先决定要加一个 5 度的旋转然后对左边的“3”和右边的“3”都加上这个旋转。这样模型学到的才是“旋转后的3”和“旋转后的3”之间的不变性这才是我们想要的鲁棒特征。3.2 模型构建共享权重的“基础编码器”设计哲学Siamese 网络的“心脏”是那个被左右分支共享的基础编码器Base Encoder。它的设计直接决定了整个系统的性能天花板。原文用了一个非常简单的Flatten Dense结构这在 MNIST 这种低分辨率、高对比度的数据上是够用的但放到真实世界的数据上就显得力不从心了。我的经验是一个优秀的基础编码器应该遵循“浅层抓纹理深层抓语义”的原则。对于 28x28 的灰度图我推荐一个轻量但高效的 CNN 结构def create_base_network(input_shape): input Input(shapeinput_shape) # 第一层小卷积核捕获局部纹理和边缘 x Conv2D(32, (3, 3), activationrelu, paddingsame)(input) x MaxPooling2D((2, 2))(x) x Dropout(0.25)(x) # 第二层稍大卷积核开始组合局部特征 x Conv2D(64, (3, 3), activationrelu, paddingsame)(x) x MaxPooling2D((2, 2))(x) x Dropout(0.25)(x) # 第三层全局特征整合 x Conv2D(128, (3, 3), activationrelu, paddingsame)(x) x GlobalAveragePooling2D()(x) # 比 Flatten 更鲁棒对空间位移不敏感 # 最后一层降维到一个紧凑的特征向量 x Dense(128, activationrelu)(x) x Dropout(0.5)(x) x Dense(64, activationNone)(x) # 最后一层不加激活保持特征向量的线性可分性 return Model(input, x)这个结构的关键点在于GlobalAveragePooling2D替代FlattenFlatten会把所有空间信息强行拉成一维丢失了位置关系。GlobalAveragePooling2D则是对每个通道的所有空间位置取平均得到一个固定长度的向量。它对图像的轻微平移、缩放具有天然的不变性这正是我们想要的。最后一层不加激活函数特征向量的值域应该是全实数-∞, ∞这样才能保证欧氏距离计算的数学严谨性。如果加了relu所有负值都会被截断为 0特征空间就不再是欧氏空间距离计算会失真。Dropout 的位置和强度我在每个MaxPooling后都加了Dropout但强度是递增的0.25 - 0.25 - 0.5。这是因为越深层的特征越抽象、越容易过拟合需要更强的正则化。提示不要试图把基础编码器做得过于庞大。我见过太多人一上来就堆 ResNet50结果在小数据集上过拟合得一塌糊涂。记住Siamese 的目标是学习一个“好的度量”而不是一个“完美的分类器”。一个结构合理、参数量适中的编码器配合精心设计的损失函数效果往往远超一个臃肿的“巨无霸”。3.3 损失函数contrastive_loss_with_margin的深度剖析与调试技巧原文的contrastive_loss_with_margin函数是整个训练过程的灵魂。但仅仅照搬代码是远远不够的你必须理解它的每一个参数、每一个运算背后的含义才能在模型不收敛时快速定位问题。让我们把公式再拆解一遍L y * d² (1-y) * max(margin - d, 0)²其中d是欧氏距离y是标签1 或 0margin是一个超参数。这个公式的精妙之处在于它的不对称性。它对正样本对y1的惩罚是d²这是一个连续、平滑、且随着 d 增大而急剧上升的函数。这意味着模型会不惜一切代价把同类图像的特征向量“挤”到一起。而对于负样本对y0它的惩罚是max(margin - d, 0)²这是一个“铰链损失”Hinge Loss。它只在d margin时起作用一旦d margin损失就为 0模型就“满意”了。这给了模型一个明确的目标不需要把所有异类都推到无穷远只要保证它们离得足够远大于 margin就行。这个设计极大地提高了训练的稳定性和效率。那么margin应该设多少这不是一个可以随意填写的数字。它必须与你的特征向量的尺度相匹配。如果你的基础编码器最后一层输出的向量其 L2 范数模长平均在 5 左右那么把margin设为 1.0 就毫无意义因为几乎所有负样本对的距离都会远大于 1.0导致第二项损失恒为 0模型就只在优化第一项变成一个单纯的“聚类器”完全丧失了区分能力。我的调试流程是先冻结编码器只训练一个简单的全连接层让模型输出一个 2 维的特征向量。然后可视化所有训练样本在这个 2D 空间里的分布。你会看到同类样本会自然形成一个个簇。测量这些簇的直径最大距离以及不同簇中心之间的最小距离。margin的初始值就应该设为“簇内直径的均值”和“簇间最小距离”的一个中间值。在 MNIST 的 2D 特征空间里这个值通常在 2.0 到 3.0 之间。在完整模型上监控训练过程中的d值分布。在 Keras 的fit过程中你可以通过自定义回调Callback来记录每个 batch 的平均d值。理想情况下正样本对的d值应该稳步下降最终稳定在一个较小的值比如 0.3~0.5而负样本对的d值应该大部分都稳定在margin之上。如果发现负样本对的d值普遍远大于margin比如 margin1.0但平均 d8.0说明margin设得太小了需要增大反之如果很多负样本对的d值都小于margin说明margin太大模型“压力”不够需要减小。注意margin不是一个一劳永逸的参数。随着训练的深入特征空间会不断演化margin也可能需要动态调整。我有一个实用的小技巧在训练的前 10 个 epoch使用一个较小的margin比如 0.5让模型快速建立初步的“聚类”概念然后在后续 epoch线性地将margin增大到目标值比如 1.2。这就像教一个孩子先让他学会区分“红”和“蓝”再慢慢教他区分“深红”和“浅红”。4. 实操过程与核心环节实现从零开始构建、训练与评估全流程4.1 环境准备与数据加载Keras 的最佳实践在开始编码之前环境的整洁性至关重要。我强烈建议使用conda创建一个纯净的虚拟环境而不是在 base 环境里折腾。这能避免各种版本冲突带来的“玄学”错误。conda create -n siamese_env python3.8 conda activate siamese_env pip install tensorflow2.11.0 scikit-learn opencv-python matplotlib选择tensorflow2.11.0是因为它对 Keras 2.x 的支持最为成熟稳定且兼容性最好。scikit-learn用于数据处理opencv-python用于图像操作比 PIL 更强大matplotlib用于结果可视化。数据加载部分原文直接用了keras.datasets.mnist.load_data()这很方便但为了教学的完整性我会展开说明每一步import numpy as np from tensorflow.keras import datasets from sklearn.model_selection import train_test_split # 1. 加载原始数据 (X_train_full, y_train_full), (X_test, y_test) datasets.mnist.load_data() # 2. 数据预处理归一化到 [0, 1] 区间并增加通道维度 X_train_full X_train_full.astype(float32) / 255.0 X_test X_test.astype(float32) / 255.0 X_train_full np.expand_dims(X_train_full, axis-1) # (60000, 28, 28, 1) X_test np.expand_dims(X_test, axis-1) # (10000, 28, 28, 1) # 3. 划分训练集和验证集 # 这里我们不按常规的 8:2而是留出一部分作为“测试专用”的正样本对 X_train, X_val, y_train, y_val train_test_split( X_train_full, y_train_full, test_size0.2, random_state42, stratifyy_train_full ) print(f训练集大小: {X_train.shape}, 验证集大小: {X_val.shape}) # 输出: 训练集大小: (48000, 28, 28, 1), 验证集大小: (12000, 28, 28, 1)这里的关键点是stratifyy_train_full。它确保了训练集和验证集里每个数字类别的比例是完全一致的。这对于 Siamese 网络尤其重要因为如果验证集里某个数字比如“9”特别少那么由它构成的正样本对就会很少最终的验证准确率就不可靠。4.2 构建完整的 Siamese 模型从输入到输出的端到端实现现在我们把前面讨论的所有组件组装成一个完整的、可训练的模型。这段代码是全文的核心我会逐行解释其设计意图。from tensorflow.keras.layers import Input, Conv2D, MaxPooling2D, GlobalAveragePooling2D, Dense, Dropout, Lambda from tensorflow.keras.models import Model from tensorflow.keras import backend as K import tensorflow as tf def euclidean_distance(vects): 计算两个向量的欧氏距离 x, y vects sum_square K.sum(K.square(x - y), axis1, keepdimsTrue) return K.sqrt(K.maximum(sum_square, K.epsilon())) # K.epsilon() 防止开根号为0 def eucl_dist_output_shape(shapes): 定义欧氏距离层的输出形状 shape1, shape2 shapes return (shape1[0], 1) # 输出是一个 (batch_size, 1) 的向量 def contrastive_loss_with_margin(margin1.0): 返回一个带有 margin 参数的对比损失函数 def contrastive_loss(y_true, y_pred): # y_true: 标签1 表示同类0 表示异类 # y_pred: 模型预测的距离 square_pred K.square(y_pred) margin_square K.square(K.maximum(margin - y_pred, 0)) return K.mean(y_true * square_pred (1 - y_true) * margin_square) return contrastive_loss # 1. 创建共享的基础编码器 input_shape (28, 28, 1) base_network create_base_network(input_shape) # 2. 定义两个输入分支 input_a Input(shapeinput_shape, nameinput_a) input_b Input(shapeinput_shape, nameinput_b) # 3. 将两个输入分别送入基础编码器得到特征向量 processed_a base_network(input_a) processed_b base_network(input_b) # 4. 计算两个特征向量的欧氏距离 distance Lambda(euclidean_distance, output_shapeeucl_dist_output_shape, namedistance)([processed_a, processed_b]) # 5. 构建最终的 Siamese 模型 siamese_model Model(inputs[input_a, input_b], outputsdistance) # 6. 编译模型 siamese_model.compile( losscontrastive_loss_with_margin(margin1.0), optimizeradam, metrics[accuracy] # 注意这里的 accuracy 是一个“伪指标”仅供观察实际评估需用自定义逻辑 ) siamese_model.summary()这段代码的亮点在于Lambda层的封装euclidean_distance和eucl_dist_output_shape被清晰地分离出来便于调试和复用。K.maximum(sum_square, K.epsilon())这是数值稳定性的一个关键细节。sum_square可能为 0当两个向量完全相同时直接开根号会导致NaN。K.epsilon()是一个极小的正数约 1e-7确保了sqrt的输入永远是正的。metrics[accuracy]的警示Keras 默认的accuracy指标在这里是“失效”的。因为它会把y_pred一个距离值和y_true0 或 1直接比较这毫无意义。我们保留它只是为了在训练日志里看到一个数字在跳动但绝不能把它当作模型的真实性能指标。真正的评估必须在后面用自定义逻辑完成。4.3 训练模型数据配对、批次生成与训练循环训练 Siamese 模型最大的挑战是如何高效地生成和喂入成对的数据。我们不能把所有可能的配对都预先生成并加载到内存里那会消耗海量内存。我们需要一个实时的、可迭代的批次生成器。class SiameseDataGenerator(tf.keras.utils.Sequence): 一个高效的 Siamese 数据生成器 def __init__(self, X, y, batch_size32, shuffleTrue, margin1.0): self.X X self.y y self.batch_size batch_size self.shuffle shuffle self.margin margin self.on_epoch_end() # 初始化索引 def __len__(self): # 每个 epoch 的步数 return int(np.floor(len(self.X) / self.batch_size)) def __getitem__(self, index): # 生成一个批次的数据 batch_indices self.indices[index * self.batch_size:(index 1) * self.batch_size] # 初始化批次数据 X1_batch np.empty((self.batch_size, 28, 28, 1)) X2_batch np.empty((self.batch_size, 28, 28, 1)) y_batch np.empty((self.batch_size, 1)) for i, idx in enumerate(batch_indices): # 随机选择一个正样本或负样本 if np.random.random() 0.5: # 正样本找一个同标签的 same_label_indices np.where(self.y self.y[idx])[0] # 排除自己 same_label_indices same_label_indices[same_label_indices ! idx] if len(same_label_indices) 0: # 如果没有其他同标签样本就随机选一个 other_idx np.random.randint(0, len(self.X)) else: other_idx np.random.choice(same_label_indices) y_batch[i] 1 else: # 负样本找一个不同标签的 diff_label_indices np.where(self.y ! self.y[idx])[0] other_idx np.random.choice(diff_label_indices) y_batch[i] 0 X1_batch[i] self.X[idx] X2_batch[i] self.X[other_idx] return [X1_batch, X2_batch], y_batch def on_epoch_end(self): # 每个 epoch 结束后打乱索引 self.indices np.arange(len(self.X)) if self.shuffle: np.random.shuffle(self.indices) # 创建生成器 train_generator SiameseDataGenerator(X_train, y_train, batch_size32, shuffleTrue) val_generator SiameseDataGenerator(X_val, y_val, batch_size32, shuffleFalse) # 开始训练 history siamese_model.fit( train_generator, epochs20, validation_dataval_generator, verbose1 )这个生成器的关键优势在于它的内存友好性和灵活性。它不会一次性把所有配对数据加载进内存而是按需生成。更重要的是它内置了正负样本的动态采样逻辑你可以随时调整np.random.random() 0.5这个概率来控制正负样本的比例这对于平衡训练过程非常有用。4.4 模型评估超越 Accuracy 的多维度验证训练完成后如何科学地评估模型仅仅看fit函数返回的val_accuracy是完全错误的。我们必须回归到 Siamese 网络的本质它是一个距离度量器。评估的核心是看它输出的距离能否正确地反映图像对的语义相似性。我的标准评估流程包含三个层次第一层阈值扫描Threshold Sweep这是最基础、最重要的一步。我们遍历一系列的threshold值比如从 0.1 到 2.0步长 0.1对验证集上的所有样本对用模型预测的距离d和当前threshold进行比较如果d threshold则预测为“同类”否则为“异类”。然后计算在每个threshold下的准确率Accuracy、精确率Precision、召回率Recall和 F1 分数。最终我们会得到一条 ROC 曲线。def evaluate_threshold(model, X_val, y_val, thresholdsnp.arange(0.1, 2.0, 0.1)): 评估不同阈值下的模型性能 from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score # 生成所有可能的验证集配对这里为了演示只取前1000个 pairs, labels [], [] for i in range(min(1000, len(X_val))): for j in range(i1, min(1000, len(X_val))): if y_val[i] y_val[j]: labels.append(1) else: labels.append(0) pairs.append([X_val[i], X_val[j]]) pairs np.array(pairs) X1_val pairs[:, 0] X2_val pairs[:, 1] labels np.array(labels) # 模型预测距离 distances model.predict([X1_val, X2_val]).flatten() results {} for th in thresholds: predictions (distances th).astype(int) results[th] { accuracy: accuracy_score(labels, predictions), precision: precision_score(labels, predictions, zero_division0), recall: recall_score(labels, predictions, zero_division0), f1: f1_score(labels, predictions, zero_division0) } return results # 执行评估 results evaluate_threshold(siamese_model, X_val, y_val) # 找出 F1 分数最高的阈值 best_th max(results.keys(), keylambda k: results[k][f1]) print(f最佳阈值: {best_th:.2f}, 对应 F1 分数: {results[best_th][f1]:.4f})第二层t-SNE 可视化这是最直观、最有说服力的验证。我们将验证集上所有图像通过基础编码器base_network提取特征然后用 t-SNE 算法将高维特征降到 2D 进行可视化。如果模型学得好你应该能看到 10 个清晰、分离的簇每个簇对应一个数字。from sklearn.manifold import TSNE import matplotlib.pyplot as plt # 提取所有验证集图像的特征 val_features base_network.predict(X_val) # 降维 tsne TSNE(n_components2, random_state42) val_features_2d tsne.fit_transform(val_features) # 绘图 plt.figure(figsize(10, 8)) scatter plt.scatter(val_features_2d[:, 0], val_features_2d[:, 1], cy_val, cmaptab10, alpha0.6) plt.colorbar(scatter) plt.title(t-SNE Visualization of Siamese Features) plt.show()第三层实际案例推理最后也是最接地气的一步挑几对真实的、有代表性的图像手动输入模型看它的输出距离是多少并结合我们的业务逻辑去解读。比如一对清晰的“3”和“3”距离是 0.23一对模糊的“3”和“3”距离是 0.41一对“3”和“8”距离是 1.87。这些具体的数字比任何宏观指标都更能让你建立起对模型行为的“手感”。5. 常见问题与排查技巧实录从训练不收敛到部署踩坑

相关新闻