孪生网络(Siamese Network)详解:相似度学习与人脸识别

引言

在传统深度学习分类场景中,我们需要大量固定类别的标注数据才能让模型收敛。但现实中却常面临挑战:

  • 公司考勤新增员工就得重训人脸识别模型?
  • 博物馆鉴定古董字画只有1个真迹样本?
  • 电商搜索里需要匹配“风格相似的小众鞋履”?

孪生网络(Siamese Network)跳出了“直接分类”的逻辑,转而学习样本间的距离/相似度,完美适配这类问题。本文将从核心原理、PyTorch极简实现、关键组件到实战技巧逐一展开。


1. 核心原理速览

1.1 孪生的本质:两个“同卵”子网络

孪生网络由结构完全一致、参数100%共享的两个子网络构成,就像双胞胎。

1.2 流程拆解(看图秒懂)

flowchart LR
    A[输入样本X1] --> B[子网络1<br/>(特征提取器)] --> C[特征向量f(X1)]
    D[输入样本X2] --> E[子网络2<br/>(=子网络1, 共享权重)] --> F[特征向量f(X2)]
    C & F --> G[距离计算层<br/>欧氏/余弦/曼哈顿]
    G --> H[相似度判断<br/>阈值对比]

1.3 为什么用共享权重?

  • 参数减半:训练效率更高
  • 特征空间一致:确保f(X1)f(X2)在同一坐标系下可比
  • 泛化性强:避免两个子网络学到不同的特征逻辑

2. PyTorch极简实现

先写一个可直接跑在灰度MNIST上的基础版,重点突出逻辑而非复杂架构。

2.1 基础孪生网络

import torch
import torch.nn as nn
import torch.nn.functional as F

class SimpleSiamese(nn.Module):
    def __init__(self, feature_dim=128):
        super().__init__()
        # 同卵特征提取器(CNN)
        self.feature_extractor = nn.Sequential(
            nn.Conv2d(1, 32, kernel_size=5), nn.ReLU(), nn.MaxPool2d(2),
            nn.Conv2d(32, 64, kernel_size=5), nn.ReLU(), nn.MaxPool2d(2),
            nn.Flatten(),
            nn.Linear(64 * 4 * 4, 256), nn.ReLU(),
            nn.Linear(256, feature_dim), nn.L2Normalize(dim=1)  # L2归一化便于距离计算
        )

    # 单个样本前向传播
    def forward_one(self, x):
        return self.feature_extractor(x)

    # 两个样本前向传播(共享权重)
    def forward(self, x1, x2):
        return self.forward_one(x1), self.forward_one(x2)

2.2 关键配套组件

对比损失(Contrastive Loss)

孪生网络的核心损失,让同类样本距离更小、异类样本距离更大(超过margin则无损失)

核心思想很简单:

  • 当两个样本为同类(标签 label=0)时,直接惩罚它们的特征距离,迫使距离趋近于 0。
  • 当两个样本为异类(标签 label=1)时,只惩罚那些距离小于 margin 的情况。换句话说,只要异类样本之间的距离已经足够大(超过 margin),就不再施加惩罚,模型就可以“偷懒”不管它们了。

这部分逻辑可以清晰地用代码表达:

class ContrastiveLoss(nn.Module):
    def __init__(self, margin=2.0):
        super().__init__()
        self.margin = margin

    def forward(self, feat1, feat2, label):
        # 计算欧氏距离(L2归一化后等价于1-余弦相似度的平方根)
        dist = F.pairwise_distance(feat1, feat2, keepdim=True)
        # 计算损失:同类(label=0)罚距离平方;异类(label=1)罚 margin - dist(且不小于0)
        loss = torch.mean(
            (1-label) * torch.pow(dist, 2) +
            label * torch.pow(torch.clamp(self.margin - dist, min=0.0), 2)
        )
        return loss

快速相似度推理

def is_similar(feat1, feat2, threshold=0.8):
    """
    判断两个特征向量是否相似
    feat1/feat2: L2归一化后的特征向量
    threshold: 余弦相似度阈值(因为L2归一化后余弦更直观)
    """
    cos_sim = F.cosine_similarity(feat1.unsqueeze(0), feat2.unsqueeze(0)).item()
    return cos_sim > threshold, cos_sim

3. 实战核心:样本对构建与数据增强

3.1 样本对构建(训练的关键)

孪生网络的输入是样本对,不是单个样本。需要平衡正负样本对(通常1:1):

import random

def make_pairs(dataset, num_pairs_per_class=3):
    """
    从PyTorch Dataset中构建样本对
    dataset: 需支持__getitem__返回(img, label)
    """
    pairs = []
    pair_labels = []  # 0=相似,1=不相似

    # 整理每个类别的索引
    class_to_indices = {}
    for idx, (_, label) in enumerate(dataset):
        if label not in class_to_indices:
            class_to_indices[label] = []
        class_to_indices[label].append(idx)
    class_list = list(class_to_indices.keys())

    # 构建样本对
    for c in class_list:
        # 正样本对:同一类
        indices_c = class_to_indices[c]
        for _ in range(num_pairs_per_class):
            if len(indices_c) >= 2:
                i, j = random.sample(indices_c, 2)
                pairs.append([dataset[i][0], dataset[j][0]])
                pair_labels.append(0)
        # 负样本对:不同类
        other_classes = [c_other for c_other in class_list if c_other != c]
        for _ in range(num_pairs_per_class):
            i = random.choice(indices_c)
            c_other = random.choice(other_classes)
            j = random.choice(class_to_indices[c_other])
            pairs.append([dataset[i][0], dataset[j][0]])
            pair_labels.append(1)
    
    return pairs, pair_labels

3.2 数据增强注意事项

  • 同一正样本对的两张图可以应用不同的小幅度增强(如随机亮度)
  • 不要应用破坏特征一致性的大幅度增强(如旋转90度以上的手写数字)

4. 常见实战问题与优化

4.1 如何选择阈值?

不要拍脑袋!用验证集的ROC曲线找最佳阈值

from sklearn.metrics import roc_curve

def find_best_threshold(model, val_loader, device):
    model.eval()
    all_dists = []
    all_labels = []

    with torch.no_grad():
        for x1, x2, label in val_loader:
            x1, x2 = x1.to(device), x2.to(device)
            feat1, feat2 = model(x1, x2)
            dist = F.pairwise_distance(feat1, feat2).cpu().numpy()
            all_dists.extend(dist)
            all_labels.extend(label.numpy())

    # 找最佳欧氏距离阈值(ROC曲线上最接近(0,1)的点)
    fpr, tpr, thresholds = roc_curve(all_labels, all_dists, pos_label=1)
    best_idx = np.argmax(tpr - fpr)
    return thresholds[best_idx]

4.2 推理太慢怎么办?

  • 预计算库特征:把已注册的人脸/签名/产品特征存到数据库/缓存,不用每次重新提取
  • 模型量化:用torch.quantization把模型从FP32压缩到INT8,速度提升3-4倍
  • ONNX/TensorRT导出:部署到生产环境时用专用推理引擎优化

5. 典型应用场景

孪生网络的核心是“小样本+相似度判断”,典型场景包括:

  1. 人脸识别/考勤:新增员工只需拍1-3张照,无需重训
  2. 签名/指纹验证:真迹样本极少,验证时只比对相似度
  3. 电商同款/相似款搜索:用用户上传的图片匹配库中风格相似的商品
  4. 缺陷检测:只有少量正常样本,检测时对比新样本与正常样本的距离

6. 总结

孪生网络是一种简单但强大的相似度学习架构,完美解决了传统分类在“小样本、新增类别”场景下的痛点。

核心要点回顾:

  1. 两个同卵子网络:参数共享,特征空间一致
  2. 对比损失:压缩同类距离,拉开异类距离
  3. 样本对训练:平衡正负样本,关键中的关键

如果需要更高的精度,可以进阶学习三元组损失(Triplet Loss)FaceNet基于Transformer的相似度模型


相关阅读