CSCI3230 T07CNN · Part 1/1 CNN on FashionMNIST
课程:CSCI3230 (ESTR3108) Fundamentals of Artificial Intelligence 本讲:T07CNN,第 1 部分 / 共 1 部分 · 原件 T07-CNN-FashionMNIST-未核.pdf(本地课件) p.1–15 定位:按打印版顺序读配套 notebook;数值来自原件保存输出,本次未训练。
原 PDF 与配套 notebook 文件名均标“未核”:本文核对了二者可见内容,但材料的学期与教师身份仍待确认,不据此推定 2026T1 作业要求。未运行训练,保存输出不等于本次实验。
配套可执行原件:T07-CNN-FashionMNIST-未核.ipynb(本地资料)。PDF 中裁掉的长代码行以 notebook 源码核对;“未核”指来源身份仍未确认,不因本次阅读自动改成官方或确定学期。
配套原代码:单元 3
以下保留原代码与原注释,注释的局限按正文解释;依单元顺序运行,下载和训练只在自行执行时发生。
import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
from torch.utils.data import DataLoader
from torchvision import datasets, transforms
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
from sklearn.metrics import confusion_matrix, classification_report
import time
# Set random seed to make sure the experiment results can be reproduced.
torch.manual_seed(42)
np.random.seed(42)
# detect whether GPU device is available.
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
print(f'Device available: {device}')
原件保存输出(非本次运行):
Device available: cuda
原导入包含绘图、混淆矩阵与分类报告工具,其中classification_report在后续没有使用。原visualize_samples虽接受n_samples,实际仍固定画2×5张;设置Torch与NumPy种子42只能约束相关随机数,不能自动保证跨设备逐位重现。网络设计原页的三个问题是:需要多少卷积块、除卷积还需什么模块、如何设置各块维度;下面沿这三个问题展开。
数据、类别与输入(p.1–2)
这篇重点不是抄出一个CNN,而是学会审计一次实验:模型读了哪些数据,更新了多少步,最后数字究竟能证明什么。只要会 T06实践的形状链,就能开始读,不需先认识所有Python库。
任务是28×28灰度服饰分类,不是MNIST手写数字。十类按ID顺序为T-shirt/top、Trouser、Pullover、Dress、Coat、Sandal、Shirt、Sneaker、Bag、Ankle boot;ID只是类别编号,数字大小不表示相似度。数据声明训练60000张、测试10000张。
像素先经ToTensor变为0到1,再用均值0.5、标准差0.5变换:。补充手算:黑0变−1、中灰0.5变0、白1变1。显示图片时反变换 ,不然颜色解释会错。p.2的2×5样本图只能检查输入和类别是否对上,不能替全数据统计。
batch128表示每批最多128张。原件训练和测试都shuffle=False,后面又提前退出,这两个设置结合起来会影响实际覆盖数据。
自定义Dataset部分只是骨架:os.listdir给文件名,打开时还需与目录拼接;从文件名取出的标签字符串要映射为训练所需整数。示例骨架不等于已适配任何目录的完整数据加载器。此处只说明问题,不改原notebook。
配套原代码:单元 5
以下保留原代码与原注释,注释的局限按正文解释;依单元顺序运行,下载和训练只在自行执行时发生。
# Define data transfromation method
# basic transformation method
transform = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize((0.5,), (0.5,))
])
# load Fashion-MNIST
train_dataset = datasets.FashionMNIST(
root='./data',
train=True,
download=True,
transform=transform
)
test_dataset = datasets.FashionMNIST(
root='./data',
train=False,
download=True,
transform=transform
)
# build data loader
batch_size = 128
train_loader = DataLoader(train_dataset, batch_size=batch_size, shuffle=False)
test_loader = DataLoader(test_dataset, batch_size=batch_size, shuffle=False)
# Fashion-MNIST category labels
class_names = ['T-shirt/top', 'Trouser', 'Pullover', 'Dress', 'Coat',
'Sandal', 'Shirt', 'Sneaker', 'Bag', 'Ankle boot']
print(f"training set size: {len(train_dataset)}")
print(f"test set size: {len(test_dataset)}")
print(f"batch size: {batch_size}")
print(f"category numbers: {len(class_names)}")
# visualization
def visualize_samples(dataset, n_samples=10):
fig, axes = plt.subplots(2, 5, figsize=(12, 5))
for i, ax in enumerate(axes.flat):
img, label = dataset[i]
img = img.squeeze() * 0.5 + 0.5
ax.imshow(img, cmap='gray')
ax.set_title(f'{class_names[label]}')
ax.axis('off')
plt.tight_layout()
plt.show()
print("\nVisualizations:")
visualize_samples(train_dataset)
原件保存输出(非本次运行):
training set size: 60000
test set size: 10000
batch size: 128
category numbers: 10
Visualizations:
配套原代码:单元 6
以下保留原代码与原注释,注释的局限按正文解释;依单元顺序运行,下载和训练只在自行执行时发生。
# if you want to define your own dataset, follow:
from torch.utils.data import Dataset
import os
from PIL import Image
class yourdataset(Dataset):
def __init__(self, data_dir, transform=None):
self.data_dir = data_dir
self.transform = transform
self.datas = os.listdir(data_dir)
def __len__(self):
return len(self.datas)
def __getitem__(self, idx):
# depends on how you save your data
img_name = self.datas[idx]
label = img_name.split('_')[0] # suppose the data is saved as 'label_id.jpg'
image = Image.open(img_name)
if self.transform:
image = self.transform(image)
return image, label
BasicCNN:形状与参数(p.3)
网络先在局部找特征,再压缩空间,最后分类。把批量轴 单独留在最前面,它不参与每张图的展平。
| 步骤 | 批形状 | 读法 |
|---|---|---|
| 输入 | B×1×28×28 | B张灰度图 |
| 卷积1与ReLU | B×32×28×28 | 32种响应图 |
| 池化1 | B×32×14×14 | 空间各减半 |
| 卷积2与ReLU | B×64×14×14 | 综合32通道产生64通道 |
| 池化2 | B×64×7×7 | 再减半 |
| 展平 | B×3136 | 每图个数 |
| 全连接1、ReLU、dropout | B×128 | 提炼成128维表示 |
| 全连接2 | B×10 | 十类logits |
卷积均3×3、stride1、padding1,所以不缩高宽。分类头的Linear(64*7*7,128)是在声明“每行应有3136个特征”,若上游尺寸变了,不能只祈祷view自动解决。
参数按“每输出需要几个权重和偏置”算:Conv1为 ;Conv2为 ;FC1为 ;FC2为 ;总421642。第一全连接占约95.2%,因此瓶颈并非必然在卷积。
dropout参数0.3在本代码表示丢弃概率,训练随机屏蔽、评价关闭。它不减少以上参数数,也不把128维永久改成约90维。最后的logits未经softmax,直接交给CrossEntropyLoss。
配套原代码:单元 8
以下保留原代码与原注释,注释的局限按正文解释;依单元顺序运行,下载和训练只在自行执行时发生。
class BasicCNN(nn.Module):
def __init__(self):
super(BasicCNN, self).__init__()
# Conv1: 1@28x28 -> 32@28x28 -> 32@14x14 (after pooling)
self.conv1 = nn.Conv2d(1, 32, kernel_size=3, padding=1)
self.pool1 = nn.MaxPool2d(2, 2)
# Conv2: 32@14x14 -> 64@14x14 -> 64@7x7 (after pooling)
self.conv2 = nn.Conv2d(32, 64, kernel_size=3, padding=1)
self.pool2 = nn.MaxPool2d(2, 2)
# Fully Connected Layers
self.fc1 = nn.Linear(64 * 7 * 7, 128)
self.fc2 = nn.Linear(128, 10)
self.dropout = nn.Dropout(0.3)
def forward(self, x):
x = self.pool1(F.relu(self.conv1(x)))
x = self.pool2(F.relu(self.conv2(x)))
x = x.view(-1, 64 * 7 * 7)
x = F.relu(self.fc1(x))
x = self.dropout(x)
x = self.fc2(x)
return x
def count_parameters(model):
return sum(p.numel() for p in model.parameters() if p.requires_grad)
print("\nNetwork structure")
basic_model = BasicCNN()
print(f"\nBasicCNN parameters: {count_parameters(basic_model):,}")
print("\nbasic CNN structure:")
print(basic_model)
原件保存输出(非本次运行):
Network structure
BasicCNN parameters: 421,642
basic CNN structure:
BasicCNN(
(conv1): Conv2d(1, 32, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
(pool1): MaxPool2d(kernel_size=2, stride=2, padding=0, dilation=1, ceil_mode=False)
(conv2): Conv2d(32, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
(pool2): MaxPool2d(kernel_size=2, stride=2, padding=0, dilation=1, ceil_mode=False)
(fc1): Linear(in_features=3136, out_features=128, bias=True)
(fc2): Linear(in_features=128, out_features=10, bias=True)
(dropout): Dropout(p=0.3, inplace=False)
)
训练循环实际上看了多少数据(p.4)
每批按“清梯度→前向→loss→反向→更新”运行。backward()求梯度,step()才更新权重;zero_grad()清梯度而不是清模型。这个主循环没有新算法,但有一个会改变实验含义的控制流:计数器加1,达到100就break。
因此一轮只处理 张,而非60000。又因为不打乱,每轮读同一段前12800张。十轮总计1000个优化步、128000次样本呈现,但独立训练样本覆盖仍只有这12800张,不是128000张不同图片。
补充比喻:一本600页的书,每次读前128页就停,读十次并不等于全书读十遍。epoch字样不能代替实际数据覆盖核验。
代码注释写让使用者移除这一演示限制;本篇不替用户修改源程序,只把保存实验到底做了什么说清。若与另一“十轮完整数据”的实验比较,必须先统一优化步数或样本呈现次数,否则改善可能来自多训练而非结构。
评价与学习率选择(p.5)
评价用eval()切换层行为,用no_grad()不记录反向图,输出argmax为预测标签。它把各batch平均loss乘该批大小后再求总平均,避免最后小batch权重过大。准确率也用总正确数除总样本数。
Adam起始学习率0.01,ReduceLROnPlateau在监控损失多轮无改善时降低步幅,factor0.5表示乘一半,patience2是容忍坏轮数设置。学习率是在控制参数更新尺度,不是模型对某类的置信度。补充自取:0.01减半成0.005,但不意味着所有权重恰好少变化一半,因为Adam状态也参与更新。
这里监控的是 test loss,因此测试数据已反馈到训练策略中。虽然没有对test batch直接反传,它仍影响何时改学习率,已承担验证集角色。较严谨的设计是从训练集分出验证集,依据它调参,保留最终测试集不参与选择。
这个区别关系到数字解释:保存的测试分数反映该实验过程,但不应宣传成“完全独立测试得到的无偏最终性能”。
配套原代码:单元 10
以下保留原代码与原注释,注释的局限按正文解释;依单元顺序运行,下载和训练只在自行执行时发生。
def train_epoch(model, train_loader, criterion, optimizer, device):
model.train()
running_loss = 0.0
correct = 0
total = 0
n = 0 # remove this in your code
for inputs, labels in train_loader:
inputs, labels = inputs.to(device), labels.to(device)
# forward propogation
optimizer.zero_grad()
outputs = model(inputs)
loss = criterion(outputs, labels)
# backward propogation and update the network
loss.backward()
optimizer.step()
running_loss += loss.item() * inputs.size(0)
_, predicted = outputs.max(1)
total += labels.size(0)
correct += predicted.eq(labels).sum().item()
# remove this in your code
n += 1
if n == 100:
break
epoch_loss = running_loss / total
epoch_acc = 100. * correct / total
return epoch_loss, epoch_acc
def evaluate(model, test_loader, criterion, device):
model.eval()
running_loss = 0.0
correct = 0
total = 0
with torch.no_grad():
for inputs, labels in test_loader:
inputs, labels = inputs.to(device), labels.to(device)
outputs = model(inputs)
loss = criterion(outputs, labels)
running_loss += loss.item() * inputs.size(0)
_, predicted = outputs.max(1)
total += labels.size(0)
correct += predicted.eq(labels).sum().item()
epoch_loss = running_loss / total
epoch_acc = 100. * correct / total
return epoch_loss, epoch_acc
def train_model(model, train_loader, test_loader, epochs=10, lr=0.01):
model = model.to(device)
criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=lr)
# optimizer scheduler, helps you automatically adjust the learning rate.
scheduler = optim.lr_scheduler.ReduceLROnPlateau(
optimizer, mode='min', factor=0.5, patience=2
)
history = {
'train_loss': [], 'train_acc': [],
'test_loss': [], 'test_acc': []
}
print(f"\nStart Training (epochs={epochs}, lr={lr})...")
start_time = time.time()
for epoch in range(epochs):
# training
train_loss, train_acc = train_epoch(
model, train_loader, criterion, optimizer, device
)
# evaluation
test_loss, test_acc = evaluate(model, test_loader, criterion, device)
# adjust lr
scheduler.step(test_loss)
# record
history['train_loss'].append(train_loss)
history['train_acc'].append(train_acc)
history['test_loss'].append(test_loss)
history['test_acc'].append(test_acc)
print(f'Epoch {epoch+1}/{epochs} - '
f'Train Loss: {train_loss:.4f}, Train Acc: {train_acc:.2f}% | '
f'Test Loss: {test_loss:.4f}, Test Acc: {test_acc:.2f}%')
training_time = time.time() - start_time
print(f'\nSuccess! Running Time: {training_time:.2f}s')
print(f'Best Test Accuracy: {max(history["test_acc"]):.2f}%')
return history
补充:最优验证轮次需要保存当时的权重
自编三轮验证损失依次0.8、0.5、0.6。如果要交付验证损失最小的模型,应保留第二轮权重;第三轮结束时内存里的当前模型不一定还是它。只记录 best_loss=0.5 却没有对应参数快照,会出现日志说“最佳0.5”,实际评价的是第三轮模型的情况。
存快照还要注意独立副本:一个普通变量指向可变模型对象,后续更新可能连同所谓“最佳模型”一起变化。若保存的是权重映射,也要确认张量是否独立复制,或直接保存当时的持久化检查点。重新载入后,在相同确定性评价设置和同一数据上复算对应指标,才完成从选择到恢复的闭环。
想继续原训练轨迹,还可能需要优化器状态、调度器状态、轮次和随机状态;只为推理通常不需要这些训练状态。两种保存目的不同。验证集用来选择最佳轮次,最终保留测试集用于评价;不能反复按测试分数挑快照,再把它当作完全独立的最终评估。
保存的基线结果与曲线(p.6–7)
原件记录第一轮训练69.34%、测试79.38%;第十轮训练88.72%、测试85.84%;测试最高85.85%出现在第九轮。它们是保存输出,本次没有运行训练或复现时间。
为什么最初测试还高于训练?训练统计来自参数在变化时的各batch,且有dropout;测试是在该轮更新完成后、关闭dropout时做的,数据也不同。因此两条曲线不是在完全同一条件下对同一模型副本测量。测试略高不自动证明数据泄漏,仍需检查流程。
图(p.7):左为loss,右为accuracy,蓝训练、橙测试,横轴是此notebook定义的十轮。 看什么:训练loss继续降,测试loss有反弹;训练拟合改善不必等于泛化改善。第十轮并非最佳测试轮。loss还能反映置信度,accuracy只记最大类是否正确,两者不必每一步同向变化。
代码用max(history['test_acc'])报告最高值,等于又用测试集选了轮次。85.85%不是“预先指定第十轮”的成绩。区分最后一轮、最佳一轮和多次运行平均,是读实验的基本习惯。
补充 · 曲线里的最佳值不等于内存中的当前模型(通识讲解;演示数据不属于原题)。
原训练函数每轮更新同一个模型,并把指标追加到history;最后打印历史最高准确率,不会因此自动把参数退回最高分那一轮。假设第3轮最高、之后又训练到第10轮,history中仍保留第3轮数字,但当前model通常是第10轮参数。若要使用最佳轮,需要在改善时保存独立状态,并在评价前明确恢复。
只保存一个指向可变对象的引用,也可能随之后训练一起改变;检查的是数值状态是否真正留存。复现实验时应把“报哪一轮的指标、用哪一轮的参数、怎样选择该轮”一起说明。这里仅按官方notebook控制流解释状态变化,不在本轮运行、改写或生成原训练产物。
配套原代码:单元 11
以下保留原代码与原注释,注释的局限按正文解释;依单元顺序运行,下载和训练只在自行执行时发生。
basic_model = BasicCNN()
basic_history = train_model(basic_model, train_loader, test_loader, epochs=10)
原件保存输出(非本次运行):
Start Training (epochs=10, lr=0.01)...
Epoch 1/10 - Train Loss: 0.8423, Train Acc: 69.34% | Test Loss: 0.5612, Test Acc: 79.38%
Epoch 2/10 - Train Loss: 0.4990, Train Acc: 81.69% | Test Loss: 0.4455, Test Acc: 83.45%
Epoch 3/10 - Train Loss: 0.4642, Train Acc: 82.83% | Test Loss: 0.4628, Test Acc: 83.15%
Epoch 4/10 - Train Loss: 0.4269, Train Acc: 84.09% | Test Loss: 0.4441, Test Acc: 83.93%
Epoch 5/10 - Train Loss: 0.4001, Train Acc: 85.09% | Test Loss: 0.4083, Test Acc: 84.73%
Epoch 6/10 - Train Loss: 0.3779, Train Acc: 85.51% | Test Loss: 0.4328, Test Acc: 84.24%
Epoch 7/10 - Train Loss: 0.3735, Train Acc: 85.87% | Test Loss: 0.4124, Test Acc: 84.85%
Epoch 8/10 - Train Loss: 0.3583, Train Acc: 86.68% | Test Loss: 0.4413, Test Acc: 84.79%
Epoch 9/10 - Train Loss: 0.3119, Train Acc: 88.34% | Test Loss: 0.3816, Test Acc: 85.85%
Epoch 10/10 - Train Loss: 0.2946, Train Acc: 88.72% | Test Loss: 0.3981, Test Acc: 85.84%
Success! Running Time: 47.73s
Best Test Accuracy: 85.85%
配套原代码:单元 12
以下保留原代码与原注释,注释的局限按正文解释;依单元顺序运行,下载和训练只在自行执行时发生。
def plot_training_history(history, title='Training History'):
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 5))
# Loss
ax1.plot(history['train_loss'], label='Train Loss', marker='o')
ax1.plot(history['test_loss'], label='Test Loss', marker='s')
ax1.set_xlabel('Epoch')
ax1.set_ylabel('Loss')
ax1.set_title(f'{title} - Loss')
ax1.legend()
ax1.grid(True, alpha=0.3)
# Accuracy
ax2.plot(history['train_acc'], label='Train Accuracy', marker='o')
ax2.plot(history['test_acc'], label='Test Accuracy', marker='s')
ax2.set_xlabel('Epoch')
ax2.set_ylabel('Accuracy (%)')
ax2.set_title(f'{title} - Accuracy')
ax2.legend()
ax2.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
plot_training_history(basic_history, 'Basic CNN')
混淆矩阵、卷积核与激活(p.8–9)
总准确率告诉你整体错多少,混淆矩阵告诉你错成什么。p.8纵轴True Label、横轴Predicted Label,所以先找真实类的行,再找预测类的列。
例:Coat行、Shirt列182,表示182件真coat被报成shirt;反向Shirt行、Coat列52,含义相反。矩阵不要求对称。该Coat行合计1000,所以“真coat中误报shirt”的比例为182/1000=18.2%;它不是“报shirt的所有样本中18.2%来自coat”,后者分母应是Shirt列总数。
原图完整混淆矩阵如下,列顺序与行顺序都按类别ID 0–9。每行合计1000;对角线总和8584,对应最后一轮85.84%,不是最高轮85.85%。
| 真实\预测 | T-shirt | Trouser | Pullover | Dress | Coat | Sandal | Shirt | Sneaker | Bag | Boot |
|---|---|---|---|---|---|---|---|---|---|---|
| T-shirt | 817 | 1 | 13 | 26 | 2 | 4 | 127 | 0 | 10 | 0 |
| Trouser | 1 | 969 | 0 | 21 | 4 | 0 | 3 | 0 | 2 | 0 |
| Pullover | 20 | 0 | 723 | 10 | 111 | 1 | 133 | 0 | 2 | 0 |
| Dress | 57 | 26 | 9 | 825 | 17 | 0 | 63 | 0 | 3 | 0 |
| Coat | 1 | 3 | 52 | 35 | 726 | 0 | 182 | 0 | 1 | 0 |
| Sandal | 0 | 0 | 0 | 0 | 0 | 970 | 0 | 21 | 2 | 7 |
| Shirt | 154 | 1 | 56 | 23 | 52 | 0 | 704 | 0 | 10 | 0 |
| Sneaker | 0 | 0 | 0 | 0 | 0 | 23 | 0 | 957 | 2 | 18 |
| Bag | 5 | 2 | 0 | 3 | 3 | 7 | 15 | 2 | 962 | 1 |
| Boot | 0 | 0 | 0 | 0 | 0 | 15 | 0 | 53 | 1 | 931 |
样本展示图的十个标签按从左到右、从上到下为Ankle boot、T-shirt/top、T-shirt/top、Dress、T-shirt/top、Pullover、Sneaker、Pullover、Sandal、Sandal,展示的是前十个样本而非每类各取一个。四组训练曲线的全部数值已随保存输出写在对应代码之后;图横轴从0到9,打印轮数从1到10,两者相差一个起始编号。
核可视化显示第一层32组3×3权重。这些是训练得到的参数,每个核各自min–max缩放到显示范围;若A核绝对值比B大,分别缩放后仍可能看起来一样亮,不能跨核直接比较强度。全常数核还要防最大减最小为0。
p.9的forward hook挂在conv1,捕获卷积输出,位置在ReLU之前,所以可能含负数。detach()让用于观察的结果不再连接反向图;它不修改模型权重。反复运行注册hook会积累回调,实际使用需要移除旧hook。
小检查:换一张图片,哪种图通常直接改变,核图还是激活图?
参考答案
激活图。推理时参数固定,核图不因单纯换输入而变;激活是输入与参数共同计算的响应。
配套原代码:单元 14
以下保留原代码与原注释,注释的局限按正文解释;依单元顺序运行,下载和训练只在自行执行时发生。
def get_predictions(model, data_loader, device):
model.eval()
all_preds = []
all_labels = []
with torch.no_grad():
for inputs, labels in data_loader:
inputs = inputs.to(device)
outputs = model(inputs)
_, predicted = outputs.max(1)
all_preds.extend(predicted.cpu().numpy())
all_labels.extend(labels.numpy())
return np.array(all_preds), np.array(all_labels)
def plot_confusion_matrix(model, test_loader, class_names, device):
preds, labels = get_predictions(model, test_loader, device)
cm = confusion_matrix(labels, preds)
plt.figure(figsize=(10, 8))
sns.heatmap(cm, annot=True, fmt='d', cmap='Blues',
xticklabels=class_names, yticklabels=class_names)
plt.title('Confusion Matrix')
plt.ylabel('True Label')
plt.xlabel('Predicted Label')
plt.xticks(rotation=45, ha='right')
plt.yticks(rotation=0)
plt.tight_layout()
plt.show()
plot_confusion_matrix(basic_model, test_loader, class_names, device)
配套原代码:单元 15
以下保留原代码与原注释,注释的局限按正文解释;依单元顺序运行,下载和训练只在自行执行时发生。
def visualize_conv_filters(model, layer_name='conv1'):
if layer_name == 'conv1':
weights = model.conv1.weight.data.cpu()
else:
return
n_filters = min(32, weights.size(0))
fig, axes = plt.subplots(4, 8, figsize=(12, 6))
for i, ax in enumerate(axes.flat):
if i < n_filters:
kernel = weights[i, 0]
kernel = (kernel - kernel.min()) / (kernel.max() - kernel.min())
ax.imshow(kernel, cmap='gray')
ax.set_title(f'Filter {i+1}')
ax.axis('off')
plt.suptitle(f'{layer_name.upper()} Filters (3x3 kernels)')
plt.tight_layout()
plt.show()
visualize_conv_filters(basic_model, 'conv1')
def visualize_feature_maps(model, image, label, device):
model.eval()
image = image.unsqueeze(0).to(device)
activation = {}
def get_activation(name):
def hook(model, input, output):
activation[name] = output.detach()
return hook
model.conv1.register_forward_hook(get_activation('conv1'))
with torch.no_grad():
_ = model(image)
feature_maps = activation['conv1'].squeeze(0).cpu()
n_features = min(16, feature_maps.size(0))
fig, axes = plt.subplots(4, 5, figsize=(12, 10))
axes[0, 0].imshow(image.squeeze().cpu() * 0.5 + 0.5, cmap='gray')
axes[0, 0].set_title(f'Input: {class_names[label]}')
axes[0, 0].axis('off')
for i in range(1, 17):
row = i // 5
col = i % 5
if i <= n_features:
axes[row, col].imshow(feature_maps[i-1], cmap='viridis')
axes[row, col].set_title(f'Feature {i}')
axes[row, col].axis('off')
plt.suptitle('Feature Maps from Conv1 Layer')
plt.tight_layout()
plt.show()
sample_img, sample_label = test_dataset[0]
visualize_feature_maps(basic_model, sample_img, sample_label, device)
特征图与更深的网络(p.10–11)
p.10左上是ankle boot,旁边16张图是不同通道响应。某些突出鞋底或轮廓,只能说这个输入在这些区域产生较强响应;不能把一个通道宣布为永远专门识别鞋底。最后空白坐标框是绘图排版剩余,不是学得空特征。
ImprovedCNN把每一组变深:通道依次1→32→32,池化后32→64→64,再池化后64→128→128。最后第三组没有再池化,故空间仍7×7,展平为6272,接256再接10。
| 部分 | 可训练参数(含卷积/线性偏置) |
|---|---|
| 六个卷积 | 286432 |
| 六个BatchNorm2d | 896 |
| FC1:6272→256 | 1605888 |
| BatchNorm1d:256 | 512 |
| FC2:256→10 | 2570 |
| 总计 | 1896298 |
六个卷积逐项为320、9248、18496、36928、73856、147584,合计286432。BatchNorm每通道有一个缩放和一个平移,所以六层共 。运行均值和方差是状态,不作为这份可训练参数计数。总数与原件1896298一致,约为基线4.5倍。
BatchNorm利用当前batch统计来归一化训练表示,评价时使用运行统计,因此train()/eval()的区别在这个模型中尤为重要。它不是把网络输出强制变成类别概率。
结构与学习率的保存结果(p.12–13)
原件ImprovedCNN初始学习率0.01,最高测试89.55%,最后一轮训练94.84%、测试89.07%。把起始学习率改为0.001后,最高测试90.55%,最后训练99.79%、测试90.55%。这都是原件保存记录,不是本次实测。
从99.79%与90.55%的差距可知,几乎记住所用训练子集并不等于在保留数据上全会。更低学习率可能帮助当前设置更细地优化,也可能需要更多步;单次结果不能推出0.001对所有网络最优。
同时比较Basic与Improved时,深度、通道、BatchNorm、分类头大小都变了。不能只看提升后就归因于“加深”一个因素。做可解释实验时,先保持其他条件,单独改一个因素,再做重复运行,使用验证集而非测试集选方案。
补充自查动作:把实验名、数据覆盖、初始学习率、步数、是否增强写成一行,再比较数值。这样能先排除“同名epoch其实训练量不同”的误会。
配套原代码:单元 18
以下保留原代码与原注释,注释的局限按正文解释;依单元顺序运行,下载和训练只在自行执行时发生。
#deeper model with BN
# deeperCNN with batchnorm
class ImprovedCNN(nn.Module):
def __init__(self):
super(ImprovedCNN, self).__init__()
# Conv1: 1@28x28 -> 32@28x28 -> 32@14x14
self.conv1_1 = nn.Conv2d(1, 32, kernel_size=3, padding=1)
self.bn1_1 = nn.BatchNorm2d(32)
self.conv1_2 = nn.Conv2d(32, 32, kernel_size=3, padding=1)
self.bn1_2 = nn.BatchNorm2d(32)
self.pool1 = nn.MaxPool2d(2, 2)
# Conv2: 32@14x14 -> 64@14x14 -> 64@7x7
self.conv2_1 = nn.Conv2d(32, 64, kernel_size=3, padding=1)
self.bn2_1 = nn.BatchNorm2d(64)
self.conv2_2 = nn.Conv2d(64, 64, kernel_size=3, padding=1)
self.bn2_2 = nn.BatchNorm2d(64)
self.pool2 = nn.MaxPool2d(2, 2)
# Conv3: 64@7x7 -> 128@7x7
self.conv3_1 = nn.Conv2d(64, 128, kernel_size=3, padding=1)
self.bn3_1 = nn.BatchNorm2d(128)
self.conv3_2 = nn.Conv2d(128, 128, kernel_size=3, padding=1)
self.bn3_2 = nn.BatchNorm2d(128)
# Fully Connected Layers
self.fc1 = nn.Linear(128 * 7 * 7, 256)
self.bn_fc = nn.BatchNorm1d(256)
self.fc2 = nn.Linear(256, 10)
self.dropout = nn.Dropout(0.3)
def forward(self, x):
x = F.relu(self.bn1_1(self.conv1_1(x)))
x = F.relu(self.bn1_2(self.conv1_2(x)))
x = self.pool1(x)
x = F.relu(self.bn2_1(self.conv2_1(x)))
x = F.relu(self.bn2_2(self.conv2_2(x)))
x = self.pool2(x)
x = F.relu(self.bn3_1(self.conv3_1(x)))
x = F.relu(self.bn3_2(self.conv3_2(x)))
x = x.view(-1, 128 * 7 * 7)
x = F.relu(self.bn_fc(self.fc1(x)))
x = self.dropout(x)
x = self.fc2(x)
return x
improved_model = ImprovedCNN()
print(f"ImprovedCNN parameters: {count_parameters(improved_model):,}")
improved_history = train_model(improved_model, train_loader, test_loader, epochs=10)
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 5))
ax1.plot(basic_history['test_loss'], label='Basic CNN', marker='o')
ax1.plot(improved_history['test_loss'], label='Improved CNN', marker='s')
ax1.set_xlabel('Epoch')
ax1.set_ylabel('Test Loss')
ax1.set_title('Model Comparison - Loss')
ax1.legend()
ax1.grid(True, alpha=0.3)
ax2.plot(basic_history['test_acc'], label='Basic CNN', marker='o')
ax2.plot(improved_history['test_acc'], label='Improved CNN', marker='s')
ax2.set_xlabel('Epoch')
ax2.set_ylabel('Test Accuracy (%)')
ax2.set_title('Model Comparison - Accuracy')
ax2.legend()
ax2.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
print(f"Basic CNN - Best: {max(basic_history['test_acc']):.2f}%")
print(f"Improved CNN - Best: {max(improved_history['test_acc']):.2f}%")
原件保存输出(非本次运行):
ImprovedCNN parameters: 1,896,298
Start Training (epochs=10, lr=0.01)...
Epoch 1/10 - Train Loss: 0.6067, Train Acc: 77.78% | Test Loss: 0.5908, Test Acc: 79.82%
Epoch 2/10 - Train Loss: 0.3753, Train Acc: 86.42% | Test Loss: 0.4471, Test Acc: 84.55%
Epoch 3/10 - Train Loss: 0.3014, Train Acc: 89.00% | Test Loss: 0.3839, Test Acc: 86.81%
Epoch 4/10 - Train Loss: 0.2578, Train Acc: 90.31% | Test Loss: 0.5330, Test Acc: 82.91%
Epoch 5/10 - Train Loss: 0.2361, Train Acc: 91.07% | Test Loss: 0.3740, Test Acc: 87.49%
Epoch 6/10 - Train Loss: 0.2053, Train Acc: 92.30% | Test Loss: 0.3638, Test Acc: 88.08%
Epoch 7/10 - Train Loss: 0.1842, Train Acc: 92.94% | Test Loss: 0.3323, Test Acc: 89.55%
Epoch 8/10 - Train Loss: 0.1567, Train Acc: 94.20% | Test Loss: 0.3764, Test Acc: 89.29%
Epoch 9/10 - Train Loss: 0.1496, Train Acc: 94.27% | Test Loss: 0.3757, Test Acc: 88.76%
Epoch 10/10 - Train Loss: 0.1351, Train Acc: 94.84% | Test Loss: 0.3745, Test Acc: 89.07%
Success! Running Time: 59.34s
Best Test Accuracy: 89.55%
Basic CNN - Best: 85.85%
Improved CNN - Best: 89.55%
配套原代码:单元 19
以下保留原代码与原注释,注释的局限按正文解释;依单元顺序运行,下载和训练只在自行执行时发生。
#with smaller learning rate 0.001
improved_model = ImprovedCNN()
improved_history_2 = train_model(improved_model, train_loader, test_loader, epochs=10, lr=0.001)
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 5))
ax1.plot(basic_history['test_loss'], label='Basic CNN', marker='o')
ax1.plot(improved_history['test_loss'], label='Improved CNN', marker='s')
ax1.plot(improved_history_2['test_loss'], label='Improved CNN v2', marker='s')
ax1.set_xlabel('Epoch')
ax1.set_ylabel('Test Loss')
ax1.set_title('Model Comparison - Loss')
ax1.legend()
ax1.grid(True, alpha=0.3)
ax2.plot(basic_history['test_acc'], label='Basic CNN', marker='o')
ax2.plot(improved_history['test_acc'], label='Improved CNN', marker='s')
ax2.plot(improved_history_2['test_acc'], label='Improved CNN v2', marker='s')
ax2.set_xlabel('Epoch')
ax2.set_ylabel('Test Accuracy (%)')
ax2.set_title('Model Comparison - Accuracy')
ax2.legend()
ax2.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
print(f"Basic CNN - Best: {max(basic_history['test_acc']):.2f}%")
print(f"Improved CNN - Best: {max(improved_history['test_acc']):.2f}%")
print(f"Improved CNN v2 - Best: {max(improved_history_2['test_acc']):.2f}%")
原件保存输出(非本次运行):
Start Training (epochs=10, lr=0.001)...
Epoch 1/10 - Train Loss: 0.4857, Train Acc: 83.45% | Test Loss: 0.3846, Test Acc: 86.60%
Epoch 2/10 - Train Loss: 0.2874, Train Acc: 89.77% | Test Loss: 0.3320, Test Acc: 88.25%
Epoch 3/10 - Train Loss: 0.2177, Train Acc: 92.16% | Test Loss: 0.3138, Test Acc: 88.54%
Epoch 4/10 - Train Loss: 0.1749, Train Acc: 93.50% | Test Loss: 0.3552, Test Acc: 87.87%
Epoch 5/10 - Train Loss: 0.1399, Train Acc: 94.91% | Test Loss: 0.3922, Test Acc: 87.84%
Epoch 6/10 - Train Loss: 0.1194, Train Acc: 95.63% | Test Loss: 0.4004, Test Acc: 87.29%
Epoch 7/10 - Train Loss: 0.0746, Train Acc: 97.30% | Test Loss: 0.3404, Test Acc: 89.39%
Epoch 8/10 - Train Loss: 0.0418, Train Acc: 98.79% | Test Loss: 0.3273, Test Acc: 90.25%
Epoch 9/10 - Train Loss: 0.0212, Train Acc: 99.48% | Test Loss: 0.3652, Test Acc: 89.90%
Epoch 10/10 - Train Loss: 0.0119, Train Acc: 99.79% | Test Loss: 0.3572, Test Acc: 90.55%
Success! Running Time: 59.29s
Best Test Accuracy: 90.55%
Basic CNN - Best: 85.85%
Improved CNN - Best: 89.55%
Improved CNN v2 - Best: 90.55%
数据增强及可比较范围(p.14–15)
本段使用RandomResizedCrop(size=28, scale=(0.95,1.0)):随机选接近整图的面积区域再缩放回28×28,后续再ToTensor和归一化。scale指采样面积比例范围,不是把宽高分别固定乘0.95;裁剪的具体长宽还受变换设置影响。
它假定轻微裁剪不改变服饰类别,让模型别只依赖固定边缘位置。裁得太狠若去掉关键特征,这个假定就可能失效。增强是改变每次看到的图,不是增加分类头参数。
本实验重新建的是BasicCNN,默认学习率0.01,最高测试86.98%;对应基线是85.85%,不是更大模型的90.55%。否则会把网络差异和增强差异混在一起。训练仍不shuffle,仍每轮100批,测试参与调度与挑最好轮的限制仍在。
p.15只有页尾,没有新实验。读完应能说清“代码声称训练几轮”和“实际看多少样本”、“保存的最好测试分数”和“独立最终测试”之间的差别。
配套原代码:单元 20
以下保留原代码与原注释,注释的局限按正文解释;依单元顺序运行,下载和训练只在自行执行时发生。
# Transform with Augmentation
transform_augmented = transforms.Compose([
transforms.RandomResizedCrop(size=28, scale=(0.95, 1.0)), # random crop and resize
transforms.ToTensor(),
transforms.Normalize((0.5,), (0.5,)),
])
train_dataset_aug = datasets.FashionMNIST(
root='./data',
train=True,
download=True,
transform=transform_augmented
)
train_loader_aug = DataLoader(
train_dataset_aug, batch_size=batch_size, shuffle=False
)
print("\ntraining with data augmentation...")
model_with_aug = BasicCNN()
aug_history = train_model(model_with_aug, train_loader_aug, test_loader, epochs=10)
plt.figure(figsize=(10, 5))
plt.plot(basic_history['test_acc'], label='Without Augmentation', marker='o')
plt.plot(aug_history['test_acc'], label='With Augmentation', marker='s')
plt.xlabel('Epoch')
plt.ylabel('Test Accuracy (%)')
plt.title('Data Augmentation Effect')
plt.legend()
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
原件保存输出(非本次运行):
training with data augmentation...
Start Training (epochs=10, lr=0.01)...
Epoch 1/10 - Train Loss: 0.8606, Train Acc: 69.14% | Test Loss: 0.5064, Test Acc: 80.77%
Epoch 2/10 - Train Loss: 0.5019, Train Acc: 81.10% | Test Loss: 0.4564, Test Acc: 83.42%
Epoch 3/10 - Train Loss: 0.4533, Train Acc: 83.08% | Test Loss: 0.4284, Test Acc: 84.61%
Epoch 4/10 - Train Loss: 0.4088, Train Acc: 84.98% | Test Loss: 0.3871, Test Acc: 85.74%
Epoch 5/10 - Train Loss: 0.3826, Train Acc: 85.77% | Test Loss: 0.3903, Test Acc: 85.90%
Epoch 6/10 - Train Loss: 0.3756, Train Acc: 86.24% | Test Loss: 0.3804, Test Acc: 86.37%
Epoch 7/10 - Train Loss: 0.3548, Train Acc: 86.85% | Test Loss: 0.3918, Test Acc: 85.69%
Epoch 8/10 - Train Loss: 0.3494, Train Acc: 87.02% | Test Loss: 0.3757, Test Acc: 86.12%
Epoch 9/10 - Train Loss: 0.3392, Train Acc: 87.16% | Test Loss: 0.3652, Test Acc: 86.73%
Epoch 10/10 - Train Loss: 0.3284, Train Acc: 87.49% | Test Loss: 0.3627, Test Acc: 86.98%
Success! Running Time: 75.60s
Best Test Accuracy: 86.98%
本讲核心考点
- CNN 张量形状、参数量、训练/评价模式承接 L07,此 notebook 为 C 级辅导证据。
- 读懂每轮实际样本数、测试集是否参与选择,才能解释准确率。
- 混淆矩阵按行条件读;权重图与激活图对象不同。
附录
本部分没有需要另展的推导或题目演练。
来源与证据
- 原件:T07-CNN-FashionMNIST-未核.pdf(本地课件),p.1–15;均为当前 PDF 页号。
- 本次重写核对本篇所列讲义页码;旧版作业/试题题面如保留,属于历史练习,不代表 2026T1 考核要求。未以未公开试题推断考试内容。
- 往年卷仅证明相应年份考过;2024 卷及 2025 节选未公开的选择题不作推断。