资讯动态

PyTorch实现Siamese Network图像相似度计算与调优

发布时间:2026/9/10 2:49:03 来源:尧图企业网站定制
简介这是一份基于PyTorch实现的Siamese Network图像相似度计算源码面向深度学习初学者与计算机视觉研究者解决图像对相似度评估问题是典型的度量学习任务。项目共21个文件压缩包仅110KB以12个Python源文件为核心完整覆盖网络结构定义、数据预处理与增强、数据加载、训练回调、损失计算及预测流程另含4张样例图片、使用说明与常见问题汇总文档以及LICENSE、.gitignore等工程配置项便于直接阅读和二次开发。设计上以VGG作为骨干网络通过共享权重的孪生分支抽取图像特征再以特征距离度量相似度对理解度量学习和对比损失具有典型参考价值。该方案可迁移至商品检索、人脸验证等实际任务中。目前已有467人学习浏览适合作为入门Siamese Network与PyTorch工程实践的开放案例。1. Siamese Network 图像相似度计算先把图像映射到可度量空间图像相似度计算的难点不在比较本身而在于怎样让模型认为两张图属于同一语义。分类网络输出的是类别概率相似度任务没有固定类别表所以需要先把图像映射到一个可度量的特征空间。Siamese Network 用两个结构相同、权重共享的分支网络分别提取特征再用欧氏距离或余弦相似度输出标量分数。PyTorch 基础框架里实现这套结构只需要把一个 nn.Module 实例调用两次反向传播时梯度自动作用在同一份参数上。这类结构最初用于签名验证现在人脸比对、电商商品检索、重复图片检测里都是主流方案。它不依赖类别数量新增类别不需要重训整个模型适合做图像检索、内容审核、资料去重的工程师也适合当作 PyTorch 入门项目。接下来按结构选型、损失函数、训练源码、推理阈值依次展开重点放在参数设置和复现时容易翻车的细节上。2. Siamese Network 的结构设计共享权重与相似度度量2.1 共享权重为什么是 Siamese Network 的根基Siamese Network 的孪生不是两个独立网络而是同一个特征提取网络被两条输入分支复用。forward 里把两张图分别喂给同一个 FeatureExtractor 实例得到向量 f1 和 f2。共享权重意味着无论输入来自哪个类别映射函数完全一致两张图被投影到同一个特征空间距离计算才具备可比性。如果两个分支各自用一套参数两个空间的坐标轴没有对齐关系算出来的距离没有任何物理意义。共享权重带来的第二个收益是样本利用率。每个批次里的一张图同时参与了正样本对和负样本对的梯度贡献参数更新信号比独立双塔更密集在小数据集上尤其明显。PyTorch 里实现共享权重不需要额外包装在 Module 的 forward 中调用同一个子模块两次即可。反向传播时梯度自动累加到同一份参数上这一行为由 autograd 机制保证。需要注意的只有一点不要把网络结构复制成两个 FeatureExtractor 再分别实例化那样参数不再绑定训练出来的两个分支各学各的特征的相似度得分会失真。2.2 基于 PyTorch 的 Feature Extractor 与双塔源码用一个三层卷积的特征提取器作为示例完整代码可以直接跑通 224x224 的 RGB 输入。import torch import torch.nn as nn import torch.nn.functional as F class FeatureExtractor(nn.Module): def __init__(self, embedding_dim128): super().__init__() self.features nn.Sequential( nn.Conv2d(3, 32, kernel_size3, padding1), nn.BatchNorm2d(32), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), nn.Conv2d(32, 64, kernel_size3, padding1), nn.BatchNorm2d(64), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), nn.Conv2d(64, 128, kernel_size3, padding1), nn.BatchNorm2d(128), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), ) # 224x224 输入经过三次池化后空间尺寸为 28x28 self.fc nn.Linear(128 * 28 * 28, embedding_dim) def forward(self, x): x self.features(x) x x.view(x.size(0), -1) return F.normalize(self.fc(x), p2, dim1) class SiameseNetwork(nn.Module): def __init__(self, extractor): super().__init__() self.extractor extractor # 同一个实例被两个分支共享 def forward(self, img1, img2): f1 self.extractor(img1) f2 self.extractor(img2) return f1, f2这段源码里最关键的一行是F.normalize(self.fc(x), p2, dim1)。它对输出的 embedding 做 L2 归一化所有特征向量落在单位球面上欧氏距离和余弦相似度在这个空间里只差一个单调变换。embedding_dim128是常见起点实际项目根据数据量在 64 到 512 之间调整即可。nn.BatchNorm2d放在卷积之后、激活之前能显著缓解训练初期梯度不稳定对比损失对特征尺度很敏感这一层不建议省。2.3 欧氏距离与余弦相似度的选型和参数边界网络输出的 embedding 本身不分远近必须搭配一个度量方式才能变成相似度分数。常见的选择是欧氏距离和余弦相似度两者在 L2 归一化后的特征上没有本质区别但搭配不同损失函数时训练行为和调参手感差异很大。度量方式计算方式常用搭配典型场景欧氏距离L2 范数距离Contrastive Loss、Triplet Loss商品图、文档图模长差异有含义余弦相似度归一化后点积归一化约束的对比损失人脸、自然图像检索光照尺度变化大如果使用归一化后的欧氏距离同类对的理想距离接近 0异类对的距离上限是 2。对比损失的 margin 如果设成 2 以上负样本对几乎永远不触发约束损失会提前收敛到一个假象。反过来余弦相似度场景里更习惯用同类相似度应大于某个下界来表达约束比如 0.5 或 0.6这个值就是余弦版本的 margin。3. 基于 PyTorch 的 Siamese Network 训练流程与损失实现3.1 图像对数据集的构建正负样本配比与采样模型学的是两张图的关系所以 Dataset 的输出不是单张图而是一个图像对加一个标签。标签 1 表示同一类别0 表示不同类别。采样策略直接影响训练质量最常见的做法是每个 batch 里正负样本对各占一半避免模型把所有样本都预测成同一类。import random from PIL import Image import torch from torch.utils.data import Dataset class SiameseDataset(Dataset): def __init__(self, image_paths, labels, transformNone): self.image_paths image_paths self.labels labels self.transform transform self.class_index {} for idx, label in enumerate(labels): self.class_index.setdefault(label, []).append(idx) def __getitem__(self, index): label self.labels[index] if torch.rand(1).item() 0.5 and len(self.class_index[label]) 1: # 采样同类样本构造正样本对 candidates [i for i in self.class_index[label] if i ! index] other random.choice(candidates) target torch.tensor(1.0) else: # 采样异类样本构造负样本对 other_label random.choice( [l for l in self.class_index.keys() if l ! label] ) other random.choice(self.class_index[other_label]) target torch.tensor(0.0) img1 Image.open(self.image_paths[index]).convert(RGB) img2 Image.open(self.image_paths[other]).convert(RGB) if self.transform: img1 self.transform(img1) img2 self.transform(img2) return img1, img2, target这里把正负对的比例固定在 1:1随机数小于 0.5 时构造正对否则构造负对。注意当某个类别只有一张图时len(self.class_index[label]) 1的条件会自动跳过正对全部落到负对分支避免程序崩溃。实际工程中如果想让负对比例略高把 0.5 调成 0.3 即可但正对太少会让网络倾向把所有输入推远表现在验证集上就是召回率骤降。3.2 Contrastive Loss 的 PyTorch 实现与 margin 调节对比损失是 Siamese Network 最经典的目标函数。正样本对要拉近距离负样本对要压到 margin 之外。PyTorch 里实现不到十行但每个参数的含义需要掰开来看。import torch.nn as nn import torch.nn.functional as F class ContrastiveLoss(nn.Module): def __init__(self, margin1.0): super().__init__() self.margin margin def forward(self, f1, f2, target): dist F.pairwise_distance(f1, f2, p2) # target1 为正对压缩距离target0 为负对推远到 margin 之外 loss target * dist.pow(2) (1 - target) * F.relu(self.margin - dist).pow(2) return loss.mean()margin 的含义是负样本对必须达到的最小距离。设得越大模型负样本的区分压力越大但也会导致难负样本的梯度过大训练震荡。经验做法是从 1.0 起步观察负对距离分布如果训练中后期绝大多数负对距离已经超过 margin可以把 margin 提到 1.5 继续收紧如果损失在 0.001 附近徘徊但验证精度不涨说明 margin 太大或难样本不足。同一个距离分布图上还能看到正对和负对的分布重叠程度这是判断模型是否收敛的最直观依据比盯着 loss 曲线可靠。3.3 训练循环、优化器与 PyTorch 环境配置要点数据、模型、损失都齐了训练循环本身反而不复杂。共享权重的梯度累加由 PyTorch 自动处理代码里不需要任何额外操作。from torch.utils.data import DataLoader from torchvision import transforms transform transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]), ]) dataset SiameseDataset(train_paths, train_labels, transformtransform) loader DataLoader(dataset, batch_size32, shuffleTrue, num_workers4) model SiameseNetwork(FeatureExtractor(embedding_dim128)) criterion ContrastiveLoss(margin1.0) optimizer torch.optim.Adam(model.parameters(), lr1e-4) scheduler torch.optim.lr_scheduler.StepLR(optimizer, step_size10, gamma0.5) for epoch in range(30): for img1, img2, target in loader: img1, img2, target img1.to(device), img2.to(device), target.to(device) f1, f2 model(img1, img2) loss criterion(f1, f2, target) optimizer.zero_grad() loss.backward() optimizer.step() scheduler.step() print(fepoch {epoch} loss {loss.item():.4f})优化器用 Adam学习率 1e-4 起步对比损失对学习率比交叉熵敏感调到 1e-3 以上很容易在训练初期出现 loss 冲高不回落。StepLR每 10 个 epoch 把学习率减半是省事的做法。环境搭建方面建议直接用 Anaconda 新建独立环境再安装 torch 和 torchvisionGPU 版先确认驱动和 CUDA 版本配套CPU 版在数据量小的实验里也够用区别只体现在训练速度上。4. 图像相似度计算的推理评估与阈值标定4.1 用训练好的模型批量计算相似度矩阵训练完成后模型的extractor就是独立的特征提取器。推理时不再需要成对输入而是把查询图和库图分别过一遍网络然后两两计算距离。常见的做法是用torch.cdist一次性算出整张距离矩阵。torch.no_grad() def compute_similarity(model, query_loader, gallery_loader, device): model.eval() gallery_embs, gallery_ids [], [] for imgs, ids in gallery_loader: embs model.extractor(imgs.to(device)) gallery_embs.append(embs.cpu()) gallery_ids.extend(ids) gallery_embs torch.cat(gallery_embs) # [N, D] query_embs, query_ids [], [] for imgs, ids in query_loader: embs model.extractor(imgs.to(device)) query_embs.append(embs.cpu()) query_ids.extend(ids) query_embs torch.cat(query_embs) # [M, D] dist torch.cdist(query_embs, gallery_embs, p2) # [M, N] sim 1.0 / (1.0 dist) # 距离转相似度范围 (0, 1] return sim, query_ids, gallery_idstorch.cdist一次算全量距离后面排序、取 top-k 都很方便。代价是内存开销随库规模平方增长库里有 10 万张图距离矩阵就是 10 万乘 10 万float32 占 40GB。遇到这种规模必须改成按查询批次循环、每次只对一批库图计算或者用 faiss 这类向量检索库代替暴力全量计算。特征维度是 128 时faiss 用 IVF 索引可以把单次查询压到毫秒级这是相似度检索落到工程里的关键一步。4.2 用 ROC 曲线标定相似度阈值相似度分数本身没有绝对意义必须结合验证集标定阈值。验证集里每对图片有一个标注1 是同类0 是异类。把这些标注和模型算出的相似度送到 ROC 曲线里找到误报率和召回率的平衡点。import numpy as np from sklearn.metrics import roc_curve, precision_recall_curve # sims 是所有样本对的相似度labels 是 0/1 标注 fpr, tpr, thresholds roc_curve(labels, sims) # 选择 TPR - FPR 差值最大的点作为阈值 best_idx np.argmax(tpr - fpr) best_threshold thresholds[best_idx] # 如果正负样本比例悬殊同时看 PR 曲线 precision, recall, pr_thresholds precision_recall_curve(labels, sims) print(fbest threshold{best_threshold:.4f})roc_curve返回的 thresholds 是按相似度降序排列的候选值np.argmax(tpr - fpr)取的是约登指数最大的点对应误报和漏报相对均衡的位置。正负样本比严重不均衡时ROC 曲线会给出过度乐观的结果这时应该以 PR 曲线的 F1 最大点为准。阈值标定完成后一定把它和模型参数一起固化下来因为不同批次训练出的模型特征分布略有漂移阈值不能跨模型复用。4.3 验证集划分里最容易犯的错Siamese Network 的验证集和普通分类任务不一样需要保证类别不重叠。如果同一张原始图片的多个变体同时出现在训练集和验证集里模型其实是在做图片级别的记忆而不是类别级别的泛化验证精度会虚高。图片级去重是相似度项目数据准备阶段最耗时的一步但也是最不能跳过的一步。另一个常见错误是验证对只包含简单的正负样本没有任何难例。比如商品检索场景里训练集里只有同一商品不同角度的正对验证集却出现了颜色相同但款式不同的负对阈值几乎必然失效。构建验证集时应该有意识地加入部分难负样本让验证精度能真实反映线上效果。5. Siamese Network 收敛慢的排查顺序与提速技巧5.1 先看难样本再动 margin模型训练到后期loss 下降缓慢是正常现象但要区分是学不动还是没学好。把每个 batch 内正对和负对的距离分布打印出来如果正对距离已经压到 0.1 以下而负对距离还徘徊在 0.5 附近说明模型在简单样本上过拟合进阶做法是难样本挖掘只选择距离最近的负样本对参与梯度计算。def select_hard_negatives(embs, labels, margin1.0): 在一个 batch 内选出距离最近但类别不同的样本对 dist torch.cdist(embs, embs, p2) same_class labels[:, None] labels[None, :] dist.masked_fill_(same_class, float(inf)) hard_dist, hard_idx torch.min(dist, dim1) return hard_dist, hard_idx难负样本的距离越近梯度越大网络会被逼着重新划分边界。但要注意难样本挖掘放大了训练噪声batch size 小于 32 时效果不稳定建议先在较大 batch 上实验。5.2 检查 embedding 归一化和梯度尺度训练不收敛时先确认F.normalize还在输出层并且只作用于最后一层。如果归一化放在了损失函数里而模型输出端漏了距离的取值范围失控margin 的意义就变了。另一种隐蔽问题是输出层初始化范围过大导致 embedding 初始就接近正交学习率跟不上。此时可以给fc层单独设置更小的初始化标准差比如nn.init.normal_(fc.weight, std0.01)让训练初期特征靠得更近网络更容易启动。5.3 用固定 checkpoint 做回归验证调参过程中最有效的约束是固定一份 checkpoint 作为基准。每调整一轮 margin、难样本策略或数据增强都用同一份验证集算一遍同类别相似度的均值向量和基准对比而不是只看 loss。用代码把每次实验的相似度矩阵、阈值、验证指标存成单独文件几轮之后就能形成一张参数对应的效果表这是收敛慢的 Siamese Network 项目里最值得做的工程投入。实验中若调整了骨干网络结构必须重新归一化特征再评估否则新旧阈值不能直接对比。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价