特征表示基础:单模态特征提取与度量学习
各位同学,咱们今天聊点实在的。跨模态对齐,说白了就是让图像和文本在同一个"语义空间"里对话。但在这之前,你得先让每种模态自己把话说清楚。这就是单模态特征提取要做的事。
我刚开始做多模态检索那会儿,踩过一个坑——直接拿预训练模型的特征去对齐,结果发现图像特征和文本特征根本不在一个量级上。后来才明白,特征表示的质量,直接决定了后续对齐的天花板。
图像特征提取:从CNN到现代架构
图像特征提取,最经典的就是CNN。你想想看,一张224x224的图片,经过卷积、池化、激活,最后变成一个2048维的向量。这个向量,就是图像的"身份证"。
我个人习惯用ResNet-50作为基线模型。为什么?因为它引入了残差连接,解决了深层网络退化问题。代码实现也很干净:
import torchvision.models as models
import torch
# 加载预训练ResNet-50,去掉最后的全连接层
resnet = models.resnet50(pretrained=True)
feature_extractor = torch.nn.Sequential(*list(resnet.children())[:-1])
# 输入: [batch, 3, 224, 224] -> 输出: [batch, 2048, 1, 1]
img_tensor = torch.randn(4, 3, 224, 224)
img_feat = feature_extractor(img_tensor).squeeze()
print(img_feat.shape) # torch.Size([4, 2048])
这里要注意,squeeze() 之后得到的是2048维的全局特征。但我在项目中遇到过一个问题——全局特征丢失了空间信息。比如"左边的狗"和"右边的猫",全局特征根本区分不了位置。
除了ResNet,现在更常用的是Vision Transformer(ViT)。它把图像切成16x16的patch,然后用Transformer处理。效果确实好,但计算量也大。我的建议是:
- 小数据集(万级):用ResNet-50/101,稳定且不易过拟合
- 大数据集(百万级):用ViT或Swin Transformer,精度更高
- 移动端部署:用MobileNet或EfficientNet-Lite
文本特征提取:从RNN到BERT
文本这边,传统做法是RNN/LSTM。但说实话,RNN有个硬伤——长距离依赖。你想想看,一句话50个词,第一个词和最后一个词的信息传递,要经过49步,梯度早就消失了。
我早期做文本检索时,用Bi-LSTM提取句子特征。前向和后向的隐藏状态拼接,得到整个句子的表示。代码大概长这样:
import torch.nn as nn
class BiLSTMEncoder(nn.Module):
def __init__(self, vocab_size, embed_dim=300, hidden_dim=256):
super().__init__()
self.embedding = nn.Embedding(vocab_size, embed_dim)
self.lstm = nn.LSTM(embed_dim, hidden_dim,
bidirectional=True, batch_first=True)
def forward(self, x):
# x: [batch, seq_len]
emb = self.embedding(x) # [batch, seq_len, 300]
output, (h_n, c_n) = self.lstm(emb)
# 取最后一个时间步的隐藏状态
last_hidden = h_n[-2:, :, :].transpose(0, 1).reshape(x.size(0), -1)
return last_hidden # [batch, 512]
但后来BERT出来之后,RNN基本就被取代了。为什么?因为BERT用了Transformer的Self-Attention,每个词都能直接看到所有词,不存在长距离问题。
用BERT提取特征,通常取[CLS] 的输出作为句子表示:
from transformers import BertTokenizer, BertModel
tokenizer = BertTokenizer.from_pretrained('bert-base-uncased')
model = BertModel.from_pretrained('bert-base-uncased')
text = "A dog is playing in the garden"
inputs = tokenizer(text, return_tensors='pt', padding=True, truncation=True)
outputs = model(**inputs)
# 取[CLS] 的特征
text_feat = outputs.last_hidden_state[:, 0, :] # [1, 768]
特征空间与度量学习基础
好,现在图像和文本各自的特征都提取出来了。但问题来了——图像特征是2048维,文本特征是768维,怎么对齐?
这就是特征空间要做的事。我们需要一个公共空间,把不同模态的特征映射进去。在这个空间里,相似的图文对距离近,不相似的距离远。
度量学习就是用来学这个距离的。常用的损失函数有几种:
| 损失函数 | 核心思想 | 适用场景 |
|---|---|---|
| Contrastive Loss | 正样本对拉近,负样本对推远,固定margin | 成对数据,二分类式匹配 |
| Triplet Loss | 锚点与正样本距离 < 锚点与负样本距离 + margin | 三元组数据,细粒度检索 |
| InfoNCE Loss | 将正样本从N个负样本中区分出来,基于softmax | 大规模对比学习(CLIP、SimCLR) |
| Margin Ranking Loss | 直接优化正样本对和负样本对的得分差值 | 排序任务,推荐系统 |
我个人最常用的是InfoNCE Loss。为什么?因为它天然适合大规模数据。你想想看,一个batch里N个图文对,每个图文对是正样本,其他N-1个都是负样本。这样一次前向就能学到很强的判别力。
代码实现也不复杂:
import torch.nn.functional as F
def info_nce_loss(image_features, text_features, temperature=0.07):
"""
image_features: [batch, dim]
text_features: [batch, dim]
"""
# 归一化
image_features = F.normalize(image_features, dim=-1)
text_features = F.normalize(text_features, dim=-1)
# 计算相似度矩阵 [batch, batch]
logits = image_features @ text_features.T / temperature
# 对角线是正样本对
batch_size = logits.shape[0]
labels = torch.arange(batch_size).to(logits.device)
# 双向损失
loss_i2t = F.cross_entropy(logits, labels)
loss_t2i = F.cross_entropy(logits.T, labels)
return (loss_i2t + loss_t2i) / 2
本章知识体系总览
下面这张图,把本章的核心逻辑串起来了。从原始数据到特征提取,再到公共空间对齐,每一步都有对应的技术和注意事项。
从这张图你能看到,整个流程是串行的:原始数据 → 单模态特征提取 → 映射到公共空间 → 度量学习优化。每一步都有多种选择,具体用哪个,取决于你的数据规模、算力资源和业务场景。