0%

练习:VGG-16解决Animals-10

让我顺着思路来做一下这道题~

1. 修改目录名字

首先,我面向AI编程,得到了修改目录的脚本(我命名为my translate program.py):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import os

translate = {"cane": "dog", "cavallo": "horse", "elefante": "elephant", "farfalla": "butterfly", "gallina": "chicken", "gatto": "cat", "mucca": "cow", "pecora": "sheep", "scoiattolo": "squirrel", "dog": "cane", "cavallo": "horse", "elephant" : "elefante", "butterfly": "farfalla", "chicken": "gallina", "cat": "gatto", "cow": "mucca", "spider": "ragno", "squirrel": "scoiattolo"}

data_dir = './raw-img'

for folder_name in os.listdir(data_dir):
old_path = os.path.join(data_dir, folder_name)
if folder_name in translate:
new_folder_name = translate[folder_name]
new_path = os.path.join(data_dir, new_folder_name)
os.rename(old_path, new_path)
print(f"✅ Renamed {folder_name}{new_folder_name}")

运行之后,我的raw-img里面的文件夹的名字都变成了英文名。

2. 开始写程序

我先:设置 transform(VGG 要求 224x224)、加载数据集、按8:2分成训练与测试数据集,利用random_split、构建Dataloader、定义类别标签。

随后,我开始构建VGG-16模型。我准备着手构建自己的模型,但是注意到Cupa前辈的笔记 (如下):

1

我有点困惑,所以VGG是nn.Module本来就有的?只需要更改最后一层为多分类即可? (此疑惑在文章后面解决)

好吧!让我先试试自己写一写VGG,看能不能成功,如果不行,我也来用module提供的VGG。

在写的过程中,我一边读《动手》的VGG-16那一篇,一边 面向AI 编程,结果发现实现时遇到了一些问题。我全部询问ChatGPT,得到了如下答案:

非常好,你提出的问题非常重要!这几步对理解深度学习模型的细节至关重要,让我一一为你解释,确保你理解每个步骤!🎯


🔹1. Sequential 是什么?

nn.SequentialPyTorch 中的一种容器,用来顺序地组织神经网络层。你可以把它理解成 “一层接一层” 的流水线。

举个例子:

1
2
3
4
5
model = nn.Sequential(
nn.Conv2d(3, 64, kernel_size=3, padding=1),
nn.ReLU(inplace=True),
nn.MaxPool2d(kernel_size=2, stride=2)
)

这样就定义了一个卷积层、ReLU激活函数和池化层。Sequential 会按照你写的顺序,依次将数据从前一层传递到后一层。

你在 VGG16 中看到的 self.features = nn.Sequential(...),就是定义了一个按顺序进行卷积、激活、池化的过程。我们每一个 Block 就是一个 Sequential 容器!


🔹2. x = x.view(x.size(0), -1) 是做什么的?

这一步是 展平(Flatten),把一个高维的张量变成一个一维的向量,以便输入到全连接层中。

为啥要展平?

卷积层的输出一般是一个三维张量 [batch_size, channels, height, width],但全连接层(nn.Linear)要求输入是一个二维张量 [batch_size, num_features],所以要展平

比如,x.size(0) 是批量大小(batch size),-1 是告诉 PyTorch 自动计算剩下的维度。

举个例子:

假设 x 是一个形状为 [32, 512, 7, 7] 的张量(32 是批量大小,512 是通道数,7x7 是特征图的高度和宽度)。我们希望把它展平为一个 [32, 512*7*7] 的二维张量,也就是把每张图的 512x7x7 的信息展成一个长向量。这个操作可以通过 x.view(x.size(0), -1) 来实现。


🔹3. 为什么第一个全连接层是 nn.Linear(512 * 7 * 7, 4096)

我们需要知道卷积层的输出尺寸。
VGG-16 的输入尺寸是 224x224,每经过一次 MaxPool2d(kernel_size=2, stride=2) 操作,图片的尺寸就会减半。假设使用了 5 次最大池化,每次池化都会使图片的尺寸缩小一半。

  • 输入尺寸:224x224
  • Block1后尺寸:112x112
  • Block2后尺寸:56x56
  • Block3后尺寸:28x28
  • Block4后尺寸:14x14
  • Block5后尺寸:7x7

经过卷积层和池化层后,最后的输出是一个 512 x 7 x 7 的张量,意味着 512 个通道,每个通道大小是 7x7

为了进入全连接层(nn.Linear),我们需要将这个三维张量(512x7x7)展平(flatten)成一维向量,展平后是 512 * 7 * 7 = 25088。
所以,第一个全连接层需要接受 25088 个输入特征:nn.Linear(512 * 7 * 7, 4096)


🔹4. nn.Dropout() 是做什么的?

nn.Dropout()正则化方法,目的是防止 过拟合

什么是过拟合?

当模型在训练数据上表现很好,但在测试数据上表现很差,说明模型学到的太多“特定于训练数据”的信息,而没有学到“通用的特征”,这种现象就是 过拟合

怎么做的?

在训练过程中,Dropout 会随机“丢弃”一部分神经元(即把它们的输出设为0),使得每次训练时模型的结构都不一样,从而增强模型的泛化能力。

例如:

1
nn.Dropout(p=0.5)

意味着有 50% 的概率,某个神经元在训练时被“丢弃”。这使得模型不能过度依赖某些特定的神经元,而能学到更强的泛化能力。


🔹5. 完整捋一遍 VGG-16 的过程

现在我们把之前的步骤汇总,捋一遍 VGG-16 的流程

VGG-16 结构

  1. 卷积块(Convolutional Blocks)
    每个卷积块包括:
    • 2~3 个卷积层(Conv2d
    • 每个卷积后都加上 ReLU 激活函数(nn.ReLU
    • 每个卷积块最后加一个 MaxPool2d 池化层,池化层的作用是将图像的尺寸缩小一半,降低计算量。
  2. 展平(Flatten)
    卷积操作输出的是一个高维的张量(形状类似 [batch_size, channels, height, width]),但全连接层需要的是一个二维张量。所以要用 view 方法将它展平,形成一个长向量([batch_size, num_features])。
  3. 全连接层(Fully Connected Layers)
    • 第一层:接收展平后的输入,尺寸是 512 * 7 * 7 = 25088,输出 4096 个神经元。
    • 第二层:输入 4096 个神经元,输出 4096 个神经元。
    • 第三层:输出 num_classes 个神经元,代表最终的分类结果(对于你的任务,是 10 类)。
  4. Dropout
    训练时,Dropout 随机丢弃一部分神经元,帮助避免过拟合。

总结一下

  • VGG-16 通过卷积层提取图像特征,然后通过全连接层进行分类。
  • 每经过一次池化层,图像的大小减小,但深度(通道数)增加,这样能提取更多高级的特征。
  • 最后通过展平(flatten)将特征输入到全连接层中。
  • Dropout 用来避免过拟合,提高模型的泛化能力。

✅ 到这里,你对整个 VGG-16 的流程理解了吧?

如果有其他细节不清楚,或者想进一步了解某个部分的工作原理,随时告诉我!接下来我们可以进入训练部分,或者有其他问题都可以继续问我哦!

既然我搞懂了,那我就接着写代码啦。我继续:用上CUDA,实例化model,定义损失函数和优化器,训练模型,评估模型 。现在,主程序基本搭建好了。开始跑吧!

3. 思考

爆显存了!!

2

于是,我向Cupa请教:
3

为什么?? 我开始这样推理:
$$
我先回忆我所学的,首先想到:\
卷积层的filter只是用来提取特征的,\
因此,不需要特别去训练它,而是可以用预训练好的卷积层参数。\
而fc层,是对提取的特征进行总结,并与最终的label联系起来,所以要训练。\
这就像:前面不用训练的部分是[卷积出来,发现一个毛绒绒的耳朵]\
后面fc要训练出来,才能是决定[毛绒绒的耳朵是猫的]
$$
我问ChatGPT我推理得对不对,它说:

你的思路基本是正确的,下面我详细说明其中的原因和背后的原理,帮助你更好地理解为什么采用“预训练卷积层 + 训练FC层”的方式会更高效,也能避免你遇到的显存问题。


1. 卷积层的作用:提取通用特征

  • 特征提取
    VGG16的卷积层主要任务是从输入图像中提取各种低级到高级的视觉特征(如边缘、纹理、形状等),这些特征在很多视觉任务中都具有普遍性。
    使用预训练的卷积层意味着这部分的参数已经在大规模数据集(比如ImageNet)上进行过学习,能够提取出有用的、具有通用性的特征。
  • 参数数量庞大
    卷积层通常包含大量参数,并且计算量非常高。如果从零开始训练卷积层,不仅需要更多的数据来防止过拟合,也会消耗大量计算资源和显存。
    而使用预训练权重可以大大减少训练时的资源需求,使得训练过程更快、更稳定。

2. 全连接层的作用:特征整合与决策

  • 任务特定的映射
    全连接层(FC层)的主要作用是将卷积层提取的特征映射到具体的类别标签上。由于不同的任务类别可能完全不同,全连接层需要根据你特定的数据集(比如Animals-10)重新进行训练,以学会如何根据提取的特征做出正确的分类决策。
  • 参数相对较少
    相较于卷积层,全连接层包含的参数要少得多,这意味着训练FC层所需的内存和计算资源也会更少。这样做既能让你专注于任务相关的部分,同时也能加快训练速度。

3. 训练速度和资源消耗问题

  • 显存压力
    当你将卷积层和FC层一起训练时,由于卷积层占用的参数巨大,再加上反向传播时需要存储大量中间结果(激活值、梯度等),这很容易爆显存。
    固定住卷积层参数,即“冻结”卷积层,只对全连接层进行梯度计算,这样可以显著降低内存占用。
  • 训练速度
    卷积层的参数量大且更新频繁,训练时反向传播计算复杂。当冻结卷积层后,只对较少参数的FC层进行反向传播,计算量骤减,自然训练速度提升。

4. 迁移学习(Transfer Learning)的优势

  • 数据效率
    预训练模型已经在大数据集上学习到了丰富的视觉表示,而你的Animals-10数据集可能数据量较少。利用预训练卷积层可以在数据相对有限的情况下获得较好的效果,避免从头开始训练带来的过拟合问题。
  • 快速收敛
    预训练的卷积层权重已经很好地初始化了网络参数,只需要针对你的任务调整FC层,使模型更快收敛,达到较高的准确率。

总结

其实就是在利用迁移学习的思想:

  • 预训练的卷积层:利用已有的大规模视觉数据学习到的通用特征,免去了从头训练高成本卷积层的麻烦,同时也节省了显存和时间。
  • 训练FC层:专注于任务相关的映射关系,将通用特征重新组合成符合你目标任务的预测结果。

因此,从理论和实践两方面来看,你的思路与推理是正确的。预训练卷积层作为一种通用且高效的特征提取器,再结合任务特定的全连接层,可以大大提高训练效率,并降低资源消耗,同时保持甚至提升模型的性能。

那我就明白啦,我还是把代码改成Cupa前辈的样子吧,嗯!开始运行

4. 运行

但我一运行,它就在往我的C盘 下载权重文件!我的C盘很小的。我的输出框显示:

1
2
3
Downloading: "https://download.pytorch.org/models/vgg16-397923af.pth" to 
C:XXX
2%|▏ | 8.52M/528M [00:09<09:18, 975kB/s]

所以,我立刻加上

1
2
import os
os.environ["TORCH_HOME"] = "D:/my_torch_cache"

现在我的C盘不会变得糟糕了,现在是

4

为了增强熟悉度,在我等待程序下载权重和训练的时候,再复诵一遍整个编程过程吧:

  • 设置 transform(VGG 要求 224x224):transform = ……
  • 加载数据集 :dataset = ……
  • 按8:2分成训练与测试数据集:train_size = int(len(dataset) * 0.8) ……, 同时利用random_split
  • 构建Dataloader:trainloader = DataLoader(trainset, batch_size=128, shuffle=True)……
  • 定义类别标签:class_names = dataset.classes
  • 构建模型:class Net(nn.Module):……
  • 用上CUDA:device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
  • 实例化model:model = XXX(……).to(device)
  • 定义损失函数和优化器:criterion = nn.CrossEntropyLoss()optimizer = optim.Adam(model.parameters(), lr=0.001)
  • 训练模型:for epoch in range(num_epochs):……
  • 评估模型:model.eval()还有with torch.no_grad():,然后for inputs, labels in testloader:……

最后,我加上了可视化的数据和参考例子。

因为epoch只有两个,所以是直线(不然实在要训练太久)

5


源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader, random_split
from torchvision.datasets import ImageFolder
from torchvision import transforms
import torch.nn.functional as F
import torchvision.models as models
import os
import random
import matplotlib.pyplot as plt
import numpy as np


os.environ["TORCH_HOME"] = "D:/my_torch_cache"
import matplotlib.pyplot as plt

# 可视化损失和准确率
def plot_metrics(train_loss_list, test_loss_list, train_acc_list, test_acc_list):
epochs = range(1, len(train_loss_list) + 1)

# 损失可视化
plt.figure(figsize=(10, 5))
plt.subplot(1, 2, 1)
plt.plot(epochs, train_loss_list, label='Training Loss', color='blue')
plt.plot(epochs, test_loss_list, label='Testing Loss', color='red')
plt.title('Training and Testing Loss')
plt.xlabel('Epoch')
plt.ylabel('Loss')
plt.legend()

# 准确率可视化
plt.subplot(1, 2, 2)
plt.plot(epochs, train_acc_list, label='Training Accuracy', color='blue')
plt.plot(epochs, test_acc_list, label='Testing Accuracy', color='red')
plt.title('Training and Testing Accuracy')
plt.xlabel('Epoch')
plt.ylabel('Accuracy')
plt.legend()

# 显示图形
plt.tight_layout()
plt.show()



# 设置 transform(VGG 要求 224x224)
transform = transforms.Compose([
transforms.Resize((224, 224)),
transforms.RandomHorizontalFlip(), # 随机水平翻转
transforms.ToTensor(), # 转换为tensor
transforms.Normalize([0.5]*3, [0.5]*3) # 归一化
])

# 加载数据集
dataset = ImageFolder("raw-img", transform=transform)

# 按8:2分成训练与测试数据集,利用random_split
train_size = int(len(dataset) * 0.8)
test_size = len(dataset) - train_size
trainset, testset = random_split(dataset, [train_size, test_size])

# Dataloader
trainloader = DataLoader(trainset,
batch_size=128, # 128
shuffle=True)
testloader = DataLoader(testset,
batch_size=128, # 128
shuffle=False)

# 类别标签
class_names = dataset.classes
print(f"检测到类别:{class_names}")

# 各种样本数量
print(f"训练集样本数: {len(trainset)}")
print(f"测试集样本数: {len(testset)}")

# VGG-16(手搓版,被注释掉了)
"""
class VGG16(nn.Module):
def __init__(self, num_classes=10): # 这个任务是10分类
super(VGG16, self).__init__()

# 卷积部分
self.features = nn.Sequential(
# Block 1
nn.Conv2d(3, 64, kernel_size=3, padding=1), # 输入3通道,输出64通道
nn.ReLU(inplace=True),
nn.Conv2d(64, 64, kernel_size=3, padding=1),
nn.ReLU(inplace=True),
nn.MaxPool2d(kernel_size=2, stride=2),

# Block 2
nn.Conv2d(64, 128, kernel_size=3, padding=1),
nn.ReLU(inplace=True),
nn.Conv2d(128, 128, kernel_size=3, padding=1),
nn.ReLU(inplace=True),
nn.MaxPool2d(kernel_size=2, stride=2),

# Block 3
nn.Conv2d(128, 256, kernel_size=3, padding=1),
nn.ReLU(inplace=True),
nn.Conv2d(256, 256, kernel_size=3, padding=1),
nn.ReLU(inplace=True),
nn.Conv2d(256, 256, kernel_size=3, padding=1),
nn.ReLU(inplace=True),
nn.MaxPool2d(kernel_size=2, stride=2),

# Block 4
nn.Conv2d(256, 512, kernel_size=3, padding=1),
nn.ReLU(inplace=True),
nn.Conv2d(512, 512, kernel_size=3, padding=1),
nn.ReLU(inplace=True),
nn.Conv2d(512, 512, kernel_size=3, padding=1),
nn.ReLU(inplace=True),
nn.MaxPool2d(kernel_size=2, stride=2),

# Block 5
nn.Conv2d(512, 512, kernel_size=3, padding=1),
nn.ReLU(inplace=True),
nn.Conv2d(512, 512, kernel_size=3, padding=1),
nn.ReLU(inplace=True),
nn.Conv2d(512, 512, kernel_size=3, padding=1),
nn.ReLU(inplace=True),
nn.MaxPool2d(kernel_size=2, stride=2)
)

# 全连接部分
self.classifier = nn.Sequential(
nn.Linear(512 * 7 * 7, 4096), # VGG输出是[512, 7, 7]
nn.ReLU(inplace=True),
nn.Dropout(),

nn.Linear(4096, 4096),
nn.ReLU(inplace=True),
nn.Dropout(),

nn.Linear(4096, num_classes) # 最后一层是数据集的类别数
)

# 向前传播
def forward(self, x):
x = self.features(x) # 卷积部分
x = x.view(x.size(0), -1) # 展平(flatten)
x = self.classifier(x) # 全连接分类
return x
"""

class Net(nn.Module):
def __init__(self, pretrained_model):
super(Net, self).__init__()

# 将预训练的VGG16模型的参数冻结(不参与训练),我们只训练全连接层
for param in pretrained_model.parameters():
param.requires_grad = False

# 取出VGG16模型的卷积层部分并添加一个BatchNorm层
self.vgg_layer = nn.Sequential(
*list(pretrained_model.children())[:-2], # 去掉VGG16模型的全连接层
nn.BatchNorm2d(512) # 添加BatchNorm层,用于标准化卷积输出
)

# 定义全连接层
self.Linear_layer1 = nn.Linear(512 * 7 * 7, 5096) # 输入维度根据VGG16的输出尺寸计算
self.Linear_layer2 = nn.Linear(5096, 512)
self.Linear_layer3 = nn.Linear(512, 128)
self.Linear_layer4 = nn.Linear(128, 10) # 输出层,10类分类问题

# 定义BatchNorm层,用于全连接层的输出
self.bn1 = nn.BatchNorm1d(5096)
self.bn2 = nn.BatchNorm1d(512)
self.bn3 = nn.BatchNorm1d(128)

# 定义前向传播过程
def forward(self, x):
# 通过VGG16的卷积层部分
x = self.vgg_layer(x)

# 将卷积层的输出展平,准备进入全连接层
x = x.view(x.size(0), -1)

# 通过全连接层和BatchNorm层进行处理
x = F.relu(self.bn1(self.Linear_layer1(x))) # 使用ReLU激活函数
x = F.relu(self.bn2(self.Linear_layer2(x)))
x = F.relu(self.bn3(self.Linear_layer3(x)))

# 最后一层输出类别预测
x = self.Linear_layer4(x)
return x
# Cuda
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
# model = VGG16(num_classes=10).to(device) # 实例化
vgg = models.vgg16(weights=models.VGG16_Weights.IMAGENET1K_V1)
model = Net(vgg).to(device) # 实例化

print("using", device)

# 定义损失函数和优化器
criterion = nn.CrossEntropyLoss() # 因为是多分类,所以要交叉熵
optimizer = optim.Adam(model.parameters(), lr=0.001) # 用Adam当优化器


train_loss_list = []
test_loss_list = []
train_acc_list = []
test_acc_list = []

# 接下来,我要训练模型了
num_epochs = 2
for epoch in range(num_epochs):
model.train()
running_loss = 0.0
correct = 0
total = 0
num_batches = len(trainloader)
batch = 0
#训练
for inputs, labels in trainloader:
batch += 1
inputs, labels = inputs.to(device), labels.to(device) # 转移到GPU

optimizer.zero_grad() # 清空梯度!
outputs = model(inputs) # 向前传播
loss = criterion(outputs, labels) # 计算loss
loss.backward() # 反向传播
optimizer.step() # 更新参数

running_loss += loss.item() # 累计loss
_, predicted = torch.max(outputs, 1) #获得预测类别
total += labels.size(0)
correct += (predicted == labels).sum().item() # 本质是对一个batch_size形状的布尔张量求和

print(f"Epoch: {epoch + 1}/{num_epochs} | Batch: {batch + 1}/{num_batches} | "
f"Average Loss: {loss.item():.3f} | Current Accuracy: {correct / total:.2f}")

train_loss = running_loss / total
train_acc = correct / total
train_loss_list.append(train_loss)
train_acc_list.append(train_acc)

# 一边测试一边评估!
model.eval() # 切换到评估模式
test_loss = 0.0
correct = 0
total = 0

with torch.no_grad():
for inputs, labels in testloader:
inputs, labels = inputs.to(device), labels.to(device)
outputs = model(inputs)
loss = criterion(outputs, labels)
test_loss += loss.item()
_, predicted = torch.max(outputs, 1)
total += labels.size(0)
correct += (predicted == labels).sum().item()
test_loss = test_loss / total
test_acc = correct / total
test_loss_list.append(test_loss)
test_acc_list.append(test_acc)
print(f"Test loss: {test_loss:.4f}, Test Accuracy: {test_acc:.4f}")

epoch_loss = running_loss / len(trainloader)
epoch_acc = correct / total

print(f"Epoch {epoch + 1}/{num_epochs}, Loss: {epoch_loss:.4f}, Accuracy: {epoch_acc:.4f}")


# 接下来,我看看测试集
model.eval() # 进入评估模式要关闭Dropout等!别忘了!
test_loss = 0.0
correct = 0
total = 0
"""注释掉了之前写的非可视化测试
with torch.no_grad():
for inputs, labels in testloader:
inputs, labels = inputs.to(device), labels.to(device)

outputs = model(inputs)
loss = criterion(outputs, labels)

test_loss += loss.item()
_, predicted = torch.max(outputs, 1)
total += labels.size(0)
correct += (predicted == labels).sum().item()

test_loss = test_loss / total
test_acc = correct / total
test_loss_list.append(test_loss)
test_acc_list.append(test_acc)

test_loss /= len(testloader)
test_acc = correct / total

print(f"Test loss: {test_loss:.4f}, Test Accuracy: {test_acc:.4f}")
"""

plot_metrics(train_loss_list, test_loss_list, train_acc_list, test_acc_list)


# 可视化预测和真实标签
def visualize_predictions(model, testloader, class_names, num_images=10):
model.eval() # 切换到评估模式
images_so_far = 0
fig = plt.figure(figsize=(12, 12))

with torch.no_grad():
for inputs, labels in testloader:
inputs, labels = inputs.to(device), labels.to(device)

outputs = model(inputs)
_, preds = torch.max(outputs, 1)

# 逐批次展示图片和标签
for i in range(inputs.size()[0]):
if images_so_far == num_images:
return
images_so_far += 1

ax = fig.add_subplot(1, num_images, images_so_far, xticks=[], yticks=[])
img = inputs[i].cpu().numpy().transpose((1, 2, 0)) # 转换为 HWC 格式
img = np.clip(img, 0, 1) # 归一化到 [0, 1] 区间
ax.imshow(img)

# 显示预测标签和真实标签
ax.set_title(f"Pred: {class_names[preds[i]]}\nTrue: {class_names[labels[i]]}")
plt.show()

# 可视化预测
visualize_predictions(model, testloader, class_names, num_images=10)