#!/usr/bin/env python3 """Generate ResNet18 notebook.""" import nbformat as nbf nb = nbf.v4.new_notebook() nb.metadata = { "kernelspec": {"display_name": "Python 3", "language": "python", "name": "python3"}, "language_info": {"name": "python", "version": "3.12.0"}, } cells = [] def md(s): cells.append(nbf.v4.new_markdown_cell(s)) def code(s): cells.append(nbf.v4.new_code_cell(s)) md("""\ # ResNet18: Residual Networks Deep residual learning with skip connections for image classification. """) md("""\ ## 背景 ResNet(2015)解决了深度网络的退化问题:层数增加后训练误差反而上升。 核心创新是**残差连接(skip connection / shortcut)**: $$y = \\mathcal{F}(x) + x$$ 让梯度可以直接通过 shortcut 传播,使得训练上百层的网络成为可能。 ResNet18 是其中最轻量的版本,用 4 个 stage 共 18 个卷积层。 """) md("""\ ## 数学原理 ### 残差块 $$\\mathcal{F}(x) = \\text{Conv3×3} \\to \\text{BN} \\to \\text{ReLU} \\to \\text{Conv3×3} \\to \\text{BN}$$ $$\\text{Output} = \\text{ReLU}(\\mathcal{F}(x) + x)$$ 当维度不匹配时(跨 stage 下采样),shortcut 需额外 Conv1×1 来对齐: $$\\text{Output} = \\text{ReLU}(\\mathcal{F}(x) + W_s \\cdot x)$$ ### 架构 ``` 7×7 Conv(stride=2) → BN → ReLU → 3×3 MaxPool(stride=2) → [BasicBlock×2, 64] stage1 → [BasicBlock×2, 128] stage2 → [BasicBlock×2, 256] stage3 → [BasicBlock×2, 512] stage4 → AvgPool → FC(512 → 15) ``` """) code("""\ import torch import torch.nn as nn import torch.optim as optim from torch.utils.data import DataLoader from torchvision import transforms from cv.resnet18.data import CelebADataset, ATTRIBUTES from cv.resnet18.model import resnet18 from utils.device import get_device device = get_device() print(f"Device: {device}") """) code("""\ # 数据加载 transform = transforms.Compose([ transforms.Resize(256), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ]) full_dataset = CelebADataset(ATTRIBUTES, num_samples=1000, transform=transform) train_dataset, val_dataset = torch.utils.data.random_split( full_dataset, [800, 200], generator=torch.Generator().manual_seed(42) ) train_loader = DataLoader(train_dataset, batch_size=128, shuffle=True, num_workers=0, pin_memory=True) val_loader = DataLoader(val_dataset, batch_size=128, shuffle=False, num_workers=0, pin_memory=True) print(f"Train: {len(train_dataset)} Val: {len(val_dataset)}") print(f"Attributes ({len(ATTRIBUTES)}): {', '.join(ATTRIBUTES)}") """) code("""\ model = resnet18(num_classes=len(ATTRIBUTES)).to(device) print(f"Parameters: {sum(p.numel() for p in model.parameters()):,}") """) md("""\ ## 训练 > ⏱ 预估耗时:**30 epoch × ~20s/epoch ≈ 10 分钟**(M4 Max, batch_size=128) """) code("""\ NUM_EPOCHS = 30 LR = 1e-3 criterion = nn.BCEWithLogitsLoss() optimizer = optim.Adam(model.parameters(), lr=LR) train_loss_hist, val_loss_hist, val_acc_hist = [], [], [] for epoch in range(1, NUM_EPOCHS + 1): model.train() train_loss = 0.0 for images, labels in train_loader: images, labels = images.to(device), labels.to(device) optimizer.zero_grad() outputs = model(images) loss = criterion(outputs, labels) loss.backward() optimizer.step() train_loss += loss.item() model.eval() val_loss = 0.0 correct = total = 0 with torch.no_grad(): for images, labels in val_loader: images, labels = images.to(device), labels.to(device) outputs = model(images) loss = criterion(outputs, labels) val_loss += loss.item() preds = (torch.sigmoid(outputs) > 0.5).float() correct += (preds == labels).sum().item() total += labels.numel() avg_train = train_loss / len(train_loader) avg_val = val_loss / len(val_loader) acc = correct / total * 100 train_loss_hist.append(avg_train) val_loss_hist.append(avg_val) val_acc_hist.append(acc) print(f"Epoch [{epoch:2d}/{NUM_EPOCHS}] Train: {avg_train:.4f} Val: {avg_val:.4f} Acc: {acc:.2f}%") """) md("""## Loss 曲线 & 验证准确率""") code("""\ import matplotlib.pyplot as plt from utils.device import get_device fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 4)) ax1.plot(train_loss_hist, label='train', marker='o') ax1.plot(val_loss_hist, label='val', marker='o') ax1.set_xlabel("Epoch"); ax1.set_ylabel("Loss"); ax1.legend(); ax1.grid(True) ax2.plot(val_acc_hist, marker='o', color='green') ax2.set_xlabel("Epoch"); ax2.set_ylabel("Val Acc (%)"); ax2.grid(True) plt.tight_layout(); plt.show() """) md("""## 每属性准确率""") code("""\ model.eval() all_preds, all_labels = [], [] with torch.no_grad(): for images, labels in val_loader: images = images.to(device) outputs = torch.sigmoid(model(images)).cpu() all_preds.append((outputs > 0.5).float()) all_labels.append(labels) all_preds = torch.cat(all_preds) all_labels = torch.cat(all_labels) print(f"{'Attribute':<20} {'Accuracy':>8}") print("-" * 30) for i, attr in enumerate(ATTRIBUTES): acc = (all_preds[:, i] == all_labels[:, i]).sum().item() / all_labels.size(0) * 100 print(f"{attr:<20} {acc:>7.2f}%") """) md("""\ ## 思考题 1. 残差连接为什么能缓解梯度消失?画出有/无 shortcut 的梯度路径。 2. 1×1 卷积在 ResNet 中有什么作用?(提示:改变通道数) 3. ResNet18 换成 ResNet34([3,4,6,3] blocks),参数量增加多少? 4. 尝试去掉 shortcut 训练(把 `+ x` 注释掉),观察 loss 有何不同。 """) nb.cells = cells out = "cv/resnet18/resnet18.ipynb" with open(out, "w") as f: nbf.write(nb, f) print(f"Generated {out}")