2. 特征表示基础:图像特征提取(CNN/ResNet)、文本特征提取(BERT/Word2Vec)、特征维度与归一化

好,咱们进入正题。多模态学习的第一步,说白了就是得让不同模态的数据「说同一种语言」。图像是像素矩阵,文本是离散词序列,这俩玩意儿天生就不对付。所以,特征表示就是给它们找个共同的「翻译官」。

我个人习惯把特征提取比作「榨汁」——你得把苹果(图像)和橙子(文本)都榨成汁,然后才能调出一杯好喝的混合果汁。这节咱们就聊聊怎么榨汁,以及榨出来的汁怎么处理才能让后续的网络喝得舒服。

2.1 图像特征提取:从CNN到ResNet

图像特征提取,我最早接触的是手工特征——SIFT、HOG那些。后来CNN一出来,整个领域就变了天。你想想看,让网络自己学特征,多省心。

2.1.1 卷积神经网络(CNN)基础

CNN的核心就三个操作:卷积、池化、激活。我简单过一下,重点说几个坑。

  • 卷积层:用卷积核在图像上滑动,提取局部特征。比如边缘、纹理。
  • 池化层:下采样,降低维度,防止过拟合。最大池化用得最多。
  • 激活函数:ReLU是标配,千万别再用sigmoid了,梯度消失太严重。

关键点:CNN提取的特征是层次化的。浅层学边缘、纹理,深层学形状、物体。多模态对齐时,我们通常取中间层或最后一层特征。

我在项目中遇到过一个问题:直接用最后一层特征做对齐,效果反而不好。为什么?因为最后一层太「抽象」了,丢失了空间细节。后来我改用倒数第二层,效果立竿见影。

2.1.2 ResNet:解决退化问题

CNN越深,理论上表达能力越强。但实际训练时,你会发现网络深到一定程度,准确率反而下降。这不是过拟合,而是「退化问题」——梯度在反向传播时越传越弱,浅层根本学不到东西。

ResNet的解决方案很简单:引入残差连接(skip connection)。说白了,就是让输入直接跳几层加到输出上。这样梯度就有了「高速公路」,可以直达浅层。

# 一个简单的残差块示例
import torch.nn as nn

class ResidualBlock(nn.Module):
    def __init__(self, in_channels, out_channels, stride=1):
        super().__init__()
        self.conv1 = nn.Conv2d(in_channels, out_channels, 3, stride, padding=1)
        self.bn1 = nn.BatchNorm2d(out_channels)
        self.relu = nn.ReLU(inplace=True)
        self.conv2 = nn.Conv2d(out_channels, out_channels, 3, padding=1)
        self.bn2 = nn.BatchNorm2d(out_channels)
        
        # 如果输入输出维度不匹配,用1x1卷积调整
        self.shortcut = nn.Sequential()
        if stride != 1 or in_channels != out_channels:
            self.shortcut = nn.Sequential(
                nn.Conv2d(in_channels, out_channels, 1, stride),
                nn.BatchNorm2d(out_channels)
            )
    
    def forward(self, x):
        out = self.relu(self.bn1(self.conv1(x)))
        out = self.bn2(self.conv2(out))
        out += self.shortcut(x)  # 残差连接
        out = self.relu(out)
        return out

我的经验:ResNet-50是图像特征提取的「万金油」。不大不小,效果稳定。如果你资源有限,ResNet-18也够用。别一上来就搞ResNet-152,训练时间翻倍,收益却微乎其微。

2.2 文本特征提取:从Word2Vec到BERT

文本特征提取,这些年变化太大了。我刚开始做NLP时,还在用one-hot编码,那叫一个稀疏。后来Word2Vec出来,大家才意识到「词向量」这玩意儿真香。

2.2.1 Word2Vec:静态词向量

Word2Vec有两种训练方式:CBOW(用上下文预测当前词)和Skip-gram(用当前词预测上下文)。我个人更常用Skip-gram,它对低频词更友好。

但Word2Vec有个硬伤:一词多义。比如「苹果」可以是水果,也可以是手机品牌。Word2Vec只会给同一个词一个向量,这就尴尬了。

避坑指南:我曾经在做一个情感分析项目时,用Word2Vec提取特征,结果「苹果手机」和「苹果好吃」被映射到相近的向量空间,导致模型完全分不清语境。后来我换成了BERT,问题才解决。

2.2.2 BERT:动态上下文表示

BERT的出现,基本终结了Word2Vec的时代。它用Transformer的注意力机制,让每个词都能「看到」整个句子的上下文。同一个词在不同语境下,会得到不同的向量。

BERT的使用方式很简单:输入一段文本,取最后一层或倒数第二层的输出作为特征。通常取[CLS] token对应的向量作为整句表示。

# 使用HuggingFace的transformers库提取BERT特征
from transformers import BertTokenizer, BertModel
import torch

tokenizer = BertTokenizer.from_pretrained('bert-base-uncased')
model = BertModel.from_pretrained('bert-base-uncased')

text = "I love multimodal learning."
inputs = tokenizer(text, return_tensors='pt', padding=True, truncation=True)

with torch.no_grad():
    outputs = model(**inputs)
    
# 取[CLS] token的向量作为句子表示
sentence_embedding = outputs.last_hidden_state[:, 0, :]
print(sentence_embedding.shape)  # torch.Size([1, 768])

维度说明:BERT-base输出768维向量,BERT-large输出1024维。这个维度在后续对齐时要注意,通常需要投影到相同维度空间。

2.3 特征维度与归一化

好,现在图像和文本的特征都提取出来了。但问题来了:图像特征可能是2048维(ResNet-50),文本特征是768维(BERT-base)。这俩维度不一样,怎么对齐?

另外,特征的数值范围也可能天差地别。图像特征经过ReLU后都是非负的,文本特征可能有正有负。如果不做归一化,模型训练时就会「偏科」——哪个模态的数值大,哪个就主导梯度。

2.3.1 维度对齐:投影层

最常见的做法是加一个投影层(projection layer),把不同模态的特征映射到同一个维度空间。比如,用一个全连接层把2048维的图像特征压缩到512维,再用另一个全连接层把768维的文本特征也压缩到512维。

import torch.nn as nn

class ProjectionLayer(nn.Module):
    def __init__(self, input_dim, output_dim=512):
        super().__init__()
        self.fc = nn.Linear(input_dim, output_dim)
        self.relu = nn.ReLU()
        self.dropout = nn.Dropout(0.1)
    
    def forward(self, x):
        x = self.fc(x)
        x = self.relu(x)
        x = self.dropout(x)
        return x

# 图像投影:2048 -> 512
image_proj = ProjectionLayer(2048, 512)
# 文本投影:768 -> 512
text_proj = ProjectionLayer(768, 512)

我的建议:投影层的输出维度不要太大,256或512就够。维度太高反而容易过拟合,而且计算量也大。我一般先试256,效果不好再调512。

2.3.2 特征归一化:L2归一化

维度对齐后,还要做归一化。最常用的是L2归一化,就是把每个特征向量除以其L2范数,让所有向量的模长都变成1。这样做的目的是消除特征尺度的影响,让模型更关注方向而不是大小。

def l2_normalize(x, dim=-1):
    """L2归一化"""
    norm = torch.norm(x, p=2, dim=dim, keepdim=True)
    return x / (norm + 1e-8)  # 加一个小常数防止除零

# 使用示例
image_feat = l2_normalize(image_feat)
text_feat = l2_normalize(text_feat)

为什么要做L2归一化?你想想看,如果两个模态的特征模长差10倍,那计算余弦相似度时,模长大的那个会主导结果。归一化后,所有特征都在同一个尺度上,相似度计算才公平。

注意:归一化要在投影层之后做,而不是之前。我之前犯过这个错——先归一化再投影,结果投影层把归一化后的分布又打乱了,等于白做。

2.3.3 其他归一化方法

除了L2归一化,还有几种常见方法:

方法 公式 适用场景
L2归一化 x / ||x||₂ 余弦相似度、对比学习
BatchNorm (x - μ) / σ 深层网络训练稳定
LayerNorm (x - μ) / σ Transformer结构、小batch
Min-Max归一化 (x - min) / (max - min) 特征值范围已知且固定

我个人在多模态对齐中,最常用的是L2归一化 + LayerNorm的组合。L2归一化保证方向一致性,LayerNorm保证数值稳定性。当然,具体用哪个还得看你的任务。

2.4 小结

这一节咱们聊了三个核心点:

  • 图像特征:CNN提取层次化特征,ResNet用残差连接解决退化问题。推荐ResNet-50作为基线。
  • 文本特征:Word2Vec是静态词向量,BERT是动态上下文表示。BERT效果更好,但计算量也更大。
  • 维度与归一化:用投影层对齐维度,用L2归一化消除尺度差异。这两步是特征融合前的「必修课」。

嗯,特征表示这块就讲这么多。下一节咱们会聊怎么把这些特征真正「融合」起来——那才是多模态学习的重头戏。