
1. 引言:多标签分类与模型架构挑战
在图像识别任务中,多标签分类(multi-label classification)是一种常见的场景,即一张图像可能同时包含多个独立的类别标签(例如,一张艺术品图像可能同时被标记为“印象派”、“风景画”和“莫奈”)。为了实现这类任务,通常会采用多头(multi-head)模型架构,即在共享的特征提取器之后,为每个分类任务设置独立的分类头。
在PyTorch中构建自定义模型时,尤其是在卷积层和全连接层之间进行张量形变(flattening)时,很容易出现张量尺寸计算错误,导致模型输入批次与输出批次不一致的问题。这会直接导致训练循环中计算损失时出现ValueError: Expected input batch_size (...) to match target batch_size (...)的错误。
2. 问题描述与初步尝试
本教程将以一个具体的案例来阐述这一问题。用户尝试为一个Wikiart数据集构建一个多标签分类模型,需要同时预测艺术家(artist)、风格(style)和流派(genre)三个标签。
最初,用户尝试基于Hugging Face的ResNetForImageClassification修改其分类头,以适应多标签任务。然而,直接修改model.classifier属性并不能让模型在forward方法中自动包含新增的多个分类头,torchinfo的摘要也证实了这一点,模型结构仍然是单分类输出。
# 初始尝试:修改预训练模型的分类头 (不适用多头输出) # model2.classifier_artist = torch.nn.Sequential(...) # model2.classifier_style = torch.nn.Sequential(...) # model2.classifier_genre = torch.nn.Sequential(...)
由于预训练模型修改的复杂性,用户转向了构建一个自定义的PyTorch模型WikiartModel。该模型包含共享的卷积层用于特征提取,然后分叉出三个独立的线性分类头。
import torch
import torch.nn as nn
import torch.nn.functional as F
class WikiartModel(nn.Module):
def __init__(self, num_artists, num_genres, num_styles):
super(WikiartModel, self).__init__()
# 共享卷积层
self.conv1 = nn.Conv2d(3, 64, kernel_size=3, padding =1)
self.conv2 = nn.Conv2d(64, 128, kernel_size=3, padding=1)
self.conv3 = nn.Conv2d(128, 256, kernel_size=3, padding=1)
self.pool = nn.MaxPool2d(2, 2) # 最大池化层
# 艺术家分类分支
self.fc_artist1 = nn.Linear(256 * 16 * 16, 512) # 错误:输入特征维度计算有误
self.fc_artist2 = nn.Linear(512, num_artists)
# 流派分类分支
self.fc_genre1 = nn.Linear(256 * 16 * 16, 512) # 错误:输入特征维度计算有误
self.fc_genre2 = nn.Linear(512, num_genres)
# 风格分类分支
self.fc_style1 = nn.Linear(256 * 16 * 16, 512) # 错误:输入特征维度计算有误
self.fc_style2 = nn.Linear(512, num_styles)
def forward(self, x):
# 共享卷积层处理
x = self.pool(F.relu(self.conv1(x)))
x = self.pool(F.relu(self.conv2(x)))
x = self.pool(F.relu(self.conv3(x)))
# 张量形变:将多维特征图展平为一维向量
x = x.view(-1, 256 * 16 * 16) # 错误:展平后的维度计算有误,且-1可能导致意外行为
# 艺术家分类分支
artists_out = F.relu(self.fc_artist1(x))
artists_out = self.fc_artist2(artists_out)
# 流派分类分支
genre_out = F.relu(self.fc_genre1(x))
genre_out = self.fc_genre2(genre_out)
# 风格分类分支
style_out = F.relu(self.fc_style1(x))
style_out = self.fc_style2(style_out)
return artists_out, genre_out, style_out
# 设置类别数量
num_artists = 129
num_genres = 11
num_styles = 27当输入数据批次大小为32(即输入张量形状为[32, 3, 224, 224])时,torchinfo显示的模型输出批次大小为98,而不是预期的32,这导致了训练循环中损失计算的ValueError。
3. 根本原因分析:张量尺寸计算错误
问题的核心在于卷积层输出的特征图尺寸与全连接层nn.Linear的in_features参数不匹配,以及forward方法中x.view操作的错误。
让我们逐步分析输入张量[32, 3, 224, 224]经过卷积和池化层后的尺寸变化:
- 输入: [Batch_Size, Channels, Height, Width] -> [32, 3, 224, 224]
-
self.conv1: nn.Conv2d(3, 64, kernel_size=3, padding=1)
- 输出尺寸公式:H_out = (H_in + 2*padding - kernel_size)/stride + 1
- 224 + 2*1 - 3 / 1 + 1 = 224
- 输出: [32, 64, 224, 224]
-
self.pool: nn.MaxPool2d(2, 2) (kernel_size=2, stride=2)
- 输出尺寸:H_out = H_in / stride
- 224 / 2 = 112
- 输出: [32, 64, 112, 112]
-
self.conv2: nn.Conv2d(64, 128, kernel_size=3, padding=1)
- 输出: [32, 128, 112, 112]
-
self.pool: nn.MaxPool2d(2, 2)
- 输出: [32, 128, 56, 56]
-
self.conv3: nn.Conv2d(128, 256, kernel_size=3, padding=1)
- 输出: [32, 256, 56, 56]
-
self.pool: nn.MaxPool2d(2, 2)
- 最终特征图输出: [32, 256, 28, 28]
因此,在进入全连接层之前,特征图的尺寸应该是 [Batch_Size, 256, 28, 28]。 当将其展平为一维向量时,除了批次大小之外的维度都应相乘:256 * 28 * 28 = 200704。
然而,原始代码中nn.Linear的in_features参数被错误地设置为256 * 16 * 16,这显然与实际的256 * 28 * 28不符。 同时,x.view(-1, 256 * 16 * 16)中的-1表示PyTorch会自动推断该维度,但由于其后指定的维度256 * 16 * 16与实际的展平尺寸不匹配,导致PyTorch在尝试展平时,不得不调整批次大小以满足总元素数量,从而产生了98这个错误的批次大小。
4. 解决方案:精确计算与正确形变
要解决此问题,需要进行两处关键修改:
- 修正nn.Linear的in_features参数: 将其更改为卷积层最终输出特征图的展平尺寸,即 256 * 28 * 28。
- 修正x.view操作: 确保展平操作正确,并且批次大小能够正确传递。推荐使用x.view(x.size(0), -1),其中x.size(0)明确指定了当前张量的批次大小,而-1则让PyTorch自动计算剩余维度的乘积。
以下是修正后的WikiartModel代码:
import torch
import torch.nn as nn
import torch.nn.functional as F
class WikiartModel(nn.Module):
def __init__(self, num_artists, num_genres, num_styles):
super(WikiartModel, self).__init__()
# 共享卷积层
self.conv1 = nn.Conv2d(3, 64, kernel_size=3, padding =1)
self.conv2 = nn.Conv2d(64, 128, kernel_size=3, padding=1)
self.conv3 = nn.Conv2d(128, 256, kernel_size=3, padding=1)
self.pool = nn.MaxPool2d(2, 2)
# 计算卷积层最终输出的特征图尺寸(例如,对于224x224输入,经过三次conv+pool后为28x28)
# 建议在模型初始化时或通过一个小的dummy_input计算得出
# 确保这里的尺寸与实际计算结果一致
self.feature_map_size = 28 # 经过三次池化后,224 -> 112 -> 56 -> 28
self.flattened_features = 256 * self.feature_map_size * self.feature_map_size # 256 * 28 * 28 = 200704
# 艺术家分类分支
self.fc_artist1 = nn.Linear(self.flattened_features, 512) # 修正此处输入特征维度
self.fc_artist2 = nn.Linear(512, num_artists)
# 流派分类分支
self.fc_genre1 = nn.Linear(self.flattened_features, 512) # 修正此处输入特征维度
self.fc_genre2 = nn.Linear(512, num_genres)
# 风格分类分支
self.fc_style1 = nn.Linear(self.flattened_features, 512) # 修正此处输入特征维度
self.fc_style2 = nn.Linear(512, num_styles)
def forward(self, x):
# 共享卷积层处理
x = self.pool(F.relu(self.conv1(x)))
x = self.pool(F.relu(self.conv2(x)))
x = self.pool(F.relu(self.conv3(x)))
# 张量形变:展平张量,保留批次大小
# x.size(0) 获取当前批次大小,-1让PyTorch自动计算剩余维度
x = x.view(x.size(0), -1)
# 艺术家分类分支
artists_out = F.relu(self.fc_artist1(x))
artists_out = self.fc_artist2(artists_out)
# 流派分类分支
genre_out = F.relu(self.fc_genre1(x))
genre_out = self.fc_genre2(genre_out)
# 风格分类分支
style_out = F.relu(self.fc_style1(x))
style_out = self.fc_style2(style_out)
return artists_out, genre_out, style_out
# 设置类别数量
num_artists = 129
num_genres = 11
num_styles = 27
# 实例化模型并进行测试 (示例)
model = WikiartModel(num_artists, num_genres, num_styles)
dummy_input = torch.randn(32, 3, 224, 224) # 批次大小为32的模拟输入
artist_output, genre_output, style_output = model(dummy_input)
print(f"Artist Output Shape: {artist_output.shape}") # 预期: [32, 129]
print(f"Genre Output Shape: {genre_output.shape}") # 预期: [32, 11]
print(f"Style Output Shape: {style_output.shape}") # 预期: [32, 27]
# 此时,torchinfo的输出也将显示正确的批次大小
# from torchinfo import summary
# summary(model, input_size=(32, 3, 224, 224))5. 注意事项与最佳实践
- 张量尺寸追踪的重要性: 在构建自定义神经网络时,务必在每个层之后打印(或使用调试工具如torchinfo)张量的形状(tensor.shape或tensor.size()),以确保数据流经网络时尺寸符合预期。这是解决这类问题的最有效方法。
- x.view(x.size(0), -1)的优势: 使用x.size(0)明确指定批次大小,而不是依赖-1来推断所有维度,可以避免在其他维度计算错误时导致批次大小被错误推断。这使得代码更健壮,不易出错。
- 动态计算展平尺寸: 对于更复杂的模型或可变输入尺寸,可以在forward方法中动态计算展平尺寸。例如,在展平之前,可以使用num_features = x.numel() // x.size(0)来获取每个样本的特征数量,然后将其用于nn.Linear层的初始化(如果模型结构允许)。但通常,对于固定输入尺寸的模型,预先计算好nn.Linear的in_features是更常见的做法。
- 预训练模型的使用: 如果希望利用预训练模型(如ResNet)的强大特征提取能力,并进行多标签分类,正确的做法是加载预训练模型,冻结其特征提取层,然后替换或在其之上添加自定义的多个分类头。这通常涉及到直接修改模型的classifier或fc属性,并确保forward方法能够正确地将特征传递给这些新的分类头。对于像Hugging Face的ResNetForImageClassification,可能需要更深入地了解其内部结构或继承并重写其forward方法以实现多头输出。
6. 总结
在PyTorch中构建自定义神经网络时,管理张量尺寸是至关重要的一环。批次大小不一致的问题通常源于卷积层输出与全连接层输入之间的尺寸不匹配,以及view操作的误用。通过精确计算卷积层输出的特征图尺寸,并采用x.view(x.size(0), -1)这种健壮的展平方式,可以有效解决这类问题,确保数据在网络中顺畅流动,并避免训练过程中的ValueError。养成良好的张量尺寸追踪习惯,将大大提高模型开发的效率和准确性。










