预计时长:6-8周 目标:深入掌握CNN原理,熟练完成图像分类、目标检测、图像分割任务
🔦 想象一下:你拿着一个小放大镜(卷积核)在一张大照片上滑动。每到一个位置,放大镜只能看到照片的一小块区域。卷积就是这个过程——用一个小窗口扫描整张图像,在每个位置计算"这里有什么特征"。
比如一个检测竖线的卷积核,就像一个专门找"竖条纹"的探测器。当它滑过图像时,遇到竖线就会"亮起来"(输出高值),遇到其他区域就"暗下去"(输出低值)。
| 概念 | 说明 | 形象比喻 | 重要程度 |
|---|---|---|---|
| 卷积核(Kernel) | 滑动窗口提取局部特征 | 🔍 像一个特征探测器,专门找某种模式 | ⭐⭐⭐⭐⭐ |
| 步幅(Stride) | 卷积核移动步长 | 👣 每次走几步,步子大则看得粗略 | ⭐⭐⭐⭐⭐ |
| 填充(Padding) | 边界处理策略 | 🖼️ 给图片加个边框,防止边缘信息丢失 | ⭐⭐⭐⭐⭐ |
| 空洞卷积(Dilation) | 扩大感受野 | 👀 隔着格子看,视野更广但不增加计算 | ⭐⭐⭐⭐ |
| 感受野(Receptive Field) | 输出对应的输入区域 | 📺 一个输出像素能"看到"多大范围的输入 | ⭐⭐⭐⭐⭐ |
输出尺寸计算:
output_size = (input_size + 2 * padding - kernel_size) / stride + 1
例如:
- 输入: 32x32
- 卷积核: 3x3
- padding: 1
- stride: 1
- 输出: (32 + 2*1 - 3) / 1 + 1 = 32x32
参数量 = (kernel_h × kernel_w × in_channels + 1) × out_channels
例如:
- 输入通道: 64
- 输出通道: 128
- 卷积核: 3x3
- 参数量 = (3 × 3 × 64 + 1) × 128 = 73,856
import torch
import torch.nn as nn
# 2D卷积层
conv = nn.Conv2d(
in_channels=3, # 输入通道数
out_channels=64, # 输出通道数
kernel_size=3, # 卷积核大小
stride=1, # 步幅
padding=1, # 填充
dilation=1, # 空洞卷积
bias=True # 是否使用偏置
)
# 输入: (batch, channels, height, width)
x = torch.randn(1, 3, 224, 224)
output = conv(x) # 输出: (1, 64, 224, 224)📊 想象一下:池化就像是"做总结"。假设你有一本100页的书,要缩写成25页的摘要:
- 最大池化(MaxPool):每4页中挑最精彩的一句话保留 —— 保留最显著的特征
- 平均池化(AvgPool):每4页算出平均重要程度 —— 保留整体信息
池化的好处:① 减少计算量(书变薄了)② 提取重要特征 ③ 增加平移不变性(猫在图片左边还是右边,都能识别出是猫)
| 类型 | 作用 | 形象比喻 | 使用场景 |
|---|---|---|---|
| MaxPool | 取最大值,保留最强特征 | 🏆 选班里最高的同学代表班级 | 常用于分类网络 |
| AvgPool | 取平均值,保留全局信息 | 📈 算全班平均身高 | 常用于语义分割 |
| AdaptivePool | 自适应输出尺寸 | 🎯 无论输入多大,输出固定大小 | 处理不同尺寸输入 |
| GlobalAvgPool | 全局平均池化 | 🌍 整张特征图求一个平均值 | 替代全连接层 |
# 最大池化
maxpool = nn.MaxPool2d(kernel_size=2, stride=2)
# 平均池化
avgpool = nn.AvgPool2d(kernel_size=2, stride=2)
# 自适应平均池化(输出固定尺寸)
adaptive_pool = nn.AdaptiveAvgPool2d((1, 1)) # 输出 1x1
# 全局平均池化
x = torch.randn(1, 512, 7, 7)
out = adaptive_pool(x) # (1, 512, 1, 1)
out = out.view(out.size(0), -1) # (1, 512)🎥 想象一下:你在看一部电影的某一帧。
- 第1层卷积:你只能看到屏幕的一小块(3×3像素)
- 第2层卷积:你通过第1层的"窗口"看,视野变大了(5×5像素)
- 第3层卷积:视野继续扩大(7×7像素)
这就像"蚂蚁看世界" vs "老鹰看世界"——层数越深,能看到的范围越大。
为什么这很重要? 检测小物体需要小感受野,检测大物体(如整只猫)需要大感受野。
感受野递推公式:
RF_n = RF_{n-1} + (kernel_size - 1) × ∏_{i=1}^{n-1} stride_i
例如:3层3x3卷积,stride=1
- 第1层: RF = 3 👁️ 只看到3×3的小区域
- 第2层: RF = 5 👁️ 视野扩展到5×5
- 第3层: RF = 7 👁️ 视野扩展到7×7
💡 重要结论:
2个3x3卷积 = 1个5x5卷积的感受野
3个3x3卷积 = 1个7x7卷积的感受野
但参数量更少!(3×3×2=18 < 5×5=25)
这就是为什么现代网络都用小卷积核堆叠,而不是大卷积核!
📊 想象一下:你是一位老师,要给来自不同学校的学生统一评分。
没有归一化时的问题:
- A学校满分100分,学生平均80分
- B学校满分150分,学生平均90分
- 直接比较分数没有意义!
BatchNorm做的事:把所有分数标准化到"均值0、方差1"的统一标准
- 就像把所有分数转换成"比平均高/低几个标准差"
- 这样网络每一层收到的数据都在相似的范围内,训练更稳定
为什么叫"Batch"Norm? 因为它在一个batch的数据上计算均值和方差,就像用全班同学的成绩来定标准。
# BatchNorm: 在batch维度归一化(像统计全班成绩)
bn = nn.BatchNorm2d(num_features=64) # 64个通道,每个通道独立归一化
# LayerNorm: 在特征维度归一化(像统计一个学生的所有科目)
ln = nn.LayerNorm(normalized_shape=[64, 224, 224])
# 使用建议:
# - CNN通常使用BatchNorm(图像特征适合跨样本统计)
# - Transformer/RNN使用LayerNorm(序列长度不固定,用LayerNorm更稳定)
# - 小batch_size时考虑GroupNorm(batch太小统计不准)- 理解卷积运算的原理和参数计算
- 手写实现2D卷积运算(numpy)
- 理解感受野的概念和计算方法
- 掌握BatchNorm的作用和原理
LeNet (1998) → AlexNet (2012) → VGG (2014) → GoogLeNet (2014)
↓
ResNeXt (2017) ← ResNet (2015) ← Inception v2/v3/v4
↓
EfficientNet (2019) → Vision Transformer (2020) → ConvNeXt (2022)
- 使用多个3x3卷积堆叠代替大卷积核
- 统一的网络结构设计
# VGG Block
def make_vgg_block(in_channels, out_channels, num_convs):
layers = []
for _ in range(num_convs):
layers.append(nn.Conv2d(in_channels, out_channels, 3, padding=1))
layers.append(nn.ReLU(inplace=True))
in_channels = out_channels
layers.append(nn.MaxPool2d(2, 2))
return nn.Sequential(*layers)
# VGG16结构
class VGG16(nn.Module):
def __init__(self, num_classes=1000):
super().__init__()
self.features = nn.Sequential(
make_vgg_block(3, 64, 2), # 224 -> 112
make_vgg_block(64, 128, 2), # 112 -> 56
make_vgg_block(128, 256, 3), # 56 -> 28
make_vgg_block(256, 512, 3), # 28 -> 14
make_vgg_block(512, 512, 3), # 14 -> 7
)
self.classifier = nn.Sequential(
nn.Linear(512 * 7 * 7, 4096),
nn.ReLU(True),
nn.Dropout(),
nn.Linear(4096, 4096),
nn.ReLU(True),
nn.Dropout(),
nn.Linear(4096, num_classes),
)🏠 想象一下:你要从1楼到10楼。
传统网络(没有残差):必须一层一层爬楼梯
- 问题:爬到第50层时,你累得走不动了(梯度消失)
- 信息传递越来越困难
ResNet(有残差连接):每隔几层有一部电梯直达
- 楼梯照走,但累了可以坐电梯
- 信息可以"抄近道"直接传到后面的层
- 梯度也能通过这条"高速公路"快速回传
另一个角度理解:
- 传统网络让每层学习"完整答案" H(x)
- 残差网络让每层学习"还差多少" F(x) = H(x) - x
- 学习"差异"比学习"完整答案"更容易!
传统网络:H(x) = F(x) 学习完整的变换
残差网络:H(x) = F(x) + x 学习残差(还差多少)
💡 为什么残差更容易学?
如果最优解是恒等映射 H(x) = x
- 传统网络需要学 F(x) = x(很难学)
- 残差网络只需要学 F(x) = 0(很容易学,权重趋近于0即可)
x ─────────────────────┐
│ │
▼ │
┌───────┐ │
│ Conv │ │
└───┬───┘ │
│ │
▼ │
┌───────┐ │
│ BN │ │
└───┬───┘ │
│ │
▼ │
┌───────┐ │
│ ReLU │ │
└───┬───┘ │
│ │
▼ │
┌───────┐ │
│ Conv │ │
└───┬───┘ │
│ │
▼ │
┌───────┐ │
│ BN │ │
└───┬───┘ │
│ │
▼ │
(+) ◄───────────────────┘
│
▼
┌───────┐
│ ReLU │
└───────┘
class BasicBlock(nn.Module):
"""ResNet基本块(用于ResNet-18/34)"""
expansion = 1
def __init__(self, in_channels, out_channels, stride=1, downsample=None):
super().__init__()
self.conv1 = nn.Conv2d(in_channels, out_channels, 3, stride, 1, bias=False)
self.bn1 = nn.BatchNorm2d(out_channels)
self.conv2 = nn.Conv2d(out_channels, out_channels, 3, 1, 1, bias=False)
self.bn2 = nn.BatchNorm2d(out_channels)
self.downsample = downsample
self.relu = nn.ReLU(inplace=True)
def forward(self, x):
identity = x
out = self.conv1(x)
out = self.bn1(out)
out = self.relu(out)
out = self.conv2(out)
out = self.bn2(out)
if self.downsample is not None:
identity = self.downsample(x)
out += identity # 残差连接
out = self.relu(out)
return out
class Bottleneck(nn.Module):
"""ResNet瓶颈块(用于ResNet-50/101/152)"""
expansion = 4
def __init__(self, in_channels, out_channels, stride=1, downsample=None):
super().__init__()
self.conv1 = nn.Conv2d(in_channels, out_channels, 1, bias=False)
self.bn1 = nn.BatchNorm2d(out_channels)
self.conv2 = nn.Conv2d(out_channels, out_channels, 3, stride, 1, bias=False)
self.bn2 = nn.BatchNorm2d(out_channels)
self.conv3 = nn.Conv2d(out_channels, out_channels * self.expansion, 1, bias=False)
self.bn3 = nn.BatchNorm2d(out_channels * self.expansion)
self.downsample = downsample
self.relu = nn.ReLU(inplace=True)
def forward(self, x):
identity = x
out = self.relu(self.bn1(self.conv1(x)))
out = self.relu(self.bn2(self.conv2(out)))
out = self.bn3(self.conv3(out))
if self.downsample is not None:
identity = self.downsample(x)
out += identity
out = self.relu(out)
return out🍳 想象一下:你要做一道菜,需要处理3种食材(3个通道)。
标准卷积(传统方式):
- 一个厨师同时处理所有食材,考虑它们的所有组合
- 工作量 = 食材种类 × 调料种类 × 处理方式 = 超级大!
深度可分离卷积(分工合作):
- 第1步 - 深度卷积:3个专门厨师,每人只处理一种食材(空间特征)
- 第2步 - 逐点卷积:1个总厨用1×1的"调味"把所有食材融合(通道融合)
- 分开做,工作量大大减少!
这就像"分工 + 汇总"的工作模式,效率高很多。
标准卷积参数量: kernel_h × kernel_w × in_channels × out_channels
= 3 × 3 × 64 × 128 = 73,728 个参数
深度可分离卷积参数量:
深度卷积: kernel_h × kernel_w × in_channels = 3 × 3 × 64 = 576
逐点卷积: 1 × 1 × in_channels × out_channels = 1 × 1 × 64 × 128 = 8,192
总计: 576 + 8,192 = 8,768 个参数
🚀 压缩了 73,728 / 8,768 ≈ 8.4 倍!手机上也能跑深度学习了!
# 深度可分离卷积
class DepthwiseSeparableConv(nn.Module):
def __init__(self, in_channels, out_channels, stride=1):
super().__init__()
# 深度卷积(每个通道单独卷积)
self.depthwise = nn.Conv2d(
in_channels, in_channels, 3, stride, 1,
groups=in_channels, bias=False # groups=in_channels 是关键
)
self.bn1 = nn.BatchNorm2d(in_channels)
# 逐点卷积(1x1卷积融合通道)
self.pointwise = nn.Conv2d(in_channels, out_channels, 1, bias=False)
self.bn2 = nn.BatchNorm2d(out_channels)
self.relu = nn.ReLU(inplace=True)
def forward(self, x):
x = self.relu(self.bn1(self.depthwise(x)))
x = self.relu(self.bn2(self.pointwise(x)))
return x三个维度同时缩放:
- 深度(depth): 网络层数
- 宽度(width): 通道数
- 分辨率(resolution): 输入图像尺寸
缩放公式:
depth = α^φ
width = β^φ
resolution = γ^φ
约束: α × β² × γ² ≈ 2
EfficientNet-B0 到 B7 通过调整 φ 实现不同规模
import torchvision.models as models
# 加载预训练EfficientNet
model = models.efficientnet_b0(pretrained=True)
# 修改最后一层用于自定义分类
num_classes = 10
model.classifier[1] = nn.Linear(model.classifier[1].in_features, num_classes)- 理解ResNet残差连接的原理
- 手写实现ResNet的BasicBlock和Bottleneck
- 理解深度可分离卷积的原理
- 能使用预训练模型进行迁移学习
🎯 目标检测要回答两个问题:
- 图里有什么?(分类)
- 在哪里?(定位)
想象你是一个寻宝猎人,拿到一张藏宝图(图像),你需要:
- 找出图上所有的宝藏(检测目标)
- 用方框圈出每个宝藏的位置(边界框)
- 说出每个宝藏是什么(类别)
| 概念 | 说明 | 形象比喻 | 重要程度 |
|---|---|---|---|
| Bounding Box | 边界框 (x, y, w, h) | 📦 用一个矩形框"圈住"目标 | ⭐⭐⭐⭐⭐ |
| IoU | 交并比,衡量框重叠程度 | 🔵🔴 两个圆重叠部分占总面积的比例 | ⭐⭐⭐⭐⭐ |
| Anchor | 预定义的参考框 | 📐 提前准备好的"模板框",预测时微调它 | ⭐⭐⭐⭐⭐ |
| NMS | 非极大值抑制,去重复框 | 🏆 选美比赛,同一区域只保留"最美"的那个框 | ⭐⭐⭐⭐⭐ |
| mAP | 平均精度均值,评估指标 | 📊 检测效果的"综合考试成绩" | ⭐⭐⭐⭐⭐ |
🍕 IoU的直观理解: 想象两个披萨盒子重叠在一起:
- 交集:两个盒子都覆盖的区域(重叠部分)
- 并集:至少一个盒子覆盖的区域(总面积,去掉重复计算)
- IoU = 交集 / 并集
IoU = 1.0 表示完美重合,IoU = 0 表示完全不重叠 通常 IoU > 0.5 认为检测成功
def calculate_iou(box1, box2):
"""
计算两个边界框的IoU
box格式: [x1, y1, x2, y2]
"""
# 计算交集
x1 = max(box1[0], box2[0])
y1 = max(box1[1], box2[1])
x2 = min(box1[2], box2[2])
y2 = min(box1[3], box2[3])
intersection = max(0, x2 - x1) * max(0, y2 - y1)
# 计算并集
area1 = (box1[2] - box1[0]) * (box1[3] - box1[1])
area2 = (box2[2] - box2[0]) * (box2[3] - box2[1])
union = area1 + area2 - intersection
return intersection / union if union > 0 else 0🏆 NMS的直观理解(选美比赛):
问题:检测一只猫,结果出现了10个框都说"这里有猫",怎么办?
NMS的做法:
- 把所有框按"置信度"排名(谁最确定这里有猫)
- 选出第1名,它肯定要保留
- 和第1名重叠很多的框(IoU > 阈值),说明它们在检测同一只猫,删掉
- 剩下的框里再选第1名,重复这个过程
最终效果:每个目标只保留最好的一个框!
def nms(boxes, scores, iou_threshold=0.5):
"""
非极大值抑制
boxes: (N, 4) - [x1, y1, x2, y2]
scores: (N,) - 置信度分数
"""
# 按分数排序
indices = scores.argsort()[::-1]
keep = []
while len(indices) > 0:
# 保留最高分的框
current = indices[0]
keep.append(current)
if len(indices) == 1:
break
# 计算当前框与其他框的IoU
current_box = boxes[current]
other_boxes = boxes[indices[1:]]
ious = np.array([calculate_iou(current_box, box) for box in other_boxes])
# 保留IoU小于阈值的框
indices = indices[1:][ious < iou_threshold]
return keep🔍 Two-Stage vs One-Stage 检测器
Two-Stage(两阶段):先找"哪里可能有东西",再仔细看"是什么"
- 就像逛街:先扫一眼哪些店铺可能有你要的东西,再进去仔细看
- 精度高,但速度慢
One-Stage(单阶段):一眼看过去,直接说"那里有猫、这里有狗"
- 就像扫一眼照片就能说出所有内容
- 速度快,适合实时检测
🚗 R-CNN系列就像"快递配送"的进化:
R-CNN (2014) - "步行送快递"
├── 使用Selective Search提取候选区域(~2000个"可能有货的地方")
├── 每个区域单独通过CNN提取特征(每个地方都单独走一趟)
└── SVM分类 + 边界框回归
问题:速度慢(每张图47秒)🐢
Fast R-CNN (2015) - "骑自行车送快递"
├── 整图通过CNN得到特征图(先画一张全城地图)
├── RoI Pooling从特征图提取候选区域特征(在地图上找位置,不用实地走)
└── 共享卷积计算,速度提升
问题:候选区域生成仍是瓶颈(还是要人工找"哪里可能有货")
Faster R-CNN (2015) - "智能导航送快递"
├── 引入Region Proposal Network (RPN)(让神经网络自己找"可能有货的地方")
├── 端到端训练(一条龙服务)
└── 使用Anchor机制(用模板框作为参考)
速度:5fps 🚀
class RPN(nn.Module):
"""Region Proposal Network"""
def __init__(self, in_channels, num_anchors=9):
super().__init__()
self.conv = nn.Conv2d(in_channels, 256, 3, 1, 1)
# 分类头:前景/背景
self.cls_head = nn.Conv2d(256, num_anchors * 2, 1)
# 回归头:边界框偏移
self.reg_head = nn.Conv2d(256, num_anchors * 4, 1)
def forward(self, x):
x = F.relu(self.conv(x))
cls_scores = self.cls_head(x) # (B, num_anchors*2, H, W)
bbox_deltas = self.reg_head(x) # (B, num_anchors*4, H, W)
return cls_scores, bbox_deltasYOLOv1 (2016): 实时检测的开创性工作
YOLOv2 (2017): 引入Anchor,批归一化
YOLOv3 (2018): 多尺度预测,残差连接
YOLOv4 (2020): CSPNet,Mish激活函数
YOLOv5 (2020): PyTorch实现,工程优化
YOLOv7 (2022): E-ELAN,复合缩放
YOLOv8 (2023): Anchor-free,解耦头
from ultralytics import YOLO
# 加载预训练模型
model = YOLO('yolov8n.pt') # n/s/m/l/x 不同规模
# 推理
results = model('image.jpg')
# 可视化结果
for result in results:
boxes = result.boxes # 边界框
masks = result.masks # 分割掩码(如果有)
probs = result.probs # 分类概率
result.show() # 显示结果
# 训练自定义数据集
model.train(
data='custom_dataset.yaml',
epochs=100,
imgsz=640,
batch=16
)
# 导出模型
model.export(format='onnx')# custom_dataset.yaml
path: /path/to/dataset
train: images/train
val: images/val
test: images/test
names:
0: person
1: car
2: dogdataset/
├── images/
│ ├── train/
│ │ ├── img001.jpg
│ │ └── ...
│ └── val/
│ └── ...
└── labels/
├── train/
│ ├── img001.txt # class x_center y_center width height (归一化)
│ └── ...
└── val/
└── ...
def calculate_ap(recalls, precisions):
"""计算单个类别的AP(使用11点插值或所有点插值)"""
# 11点插值法
ap = 0
for t in np.arange(0, 1.1, 0.1):
if np.sum(recalls >= t) == 0:
p = 0
else:
p = np.max(precisions[recalls >= t])
ap += p / 11
return ap
# mAP = 所有类别AP的平均值
# mAP@0.5: IoU阈值为0.5时的mAP
# mAP@0.5:0.95: IoU从0.5到0.95,步长0.05的平均mAP- 理解IoU和NMS的原理
- 手写实现IoU计算和NMS算法
- 理解Anchor机制
- 使用YOLOv8完成自定义目标检测任务
🎨 图像分割就是给图像"涂色"
想象你有一张照片,要给它做标注:
- 图像分类:这张图里有什么?"有猫" ✓
- 目标检测:猫在哪?画个框
- 图像分割:猫的每一个像素在哪?逐像素涂色!
分割是最精细的任务——不只是画框,而是精确描绘出物体的轮廓。
| 类型 | 说明 | 形象比喻 | 输出 | 代表模型 |
|---|---|---|---|---|
| 语义分割 | 像素级分类 | 🎨 给每类物体涂不同颜色(所有猫涂红色,所有狗涂蓝色) | 每个像素的类别 | FCN, U-Net, DeepLab |
| 实例分割 | 区分同类不同个体 | 🐱🐱 两只猫涂不同颜色(猫1涂红色,猫2涂粉色) | 每个实例的掩码 | Mask R-CNN |
| 全景分割 | 语义+实例 | 🌍 背景用语义分割,前景用实例分割 | 背景+实例掩码 | Panoptic FPN |
class FCN(nn.Module):
"""全卷积网络"""
def __init__(self, num_classes):
super().__init__()
# 编码器(使用预训练VGG)
vgg = models.vgg16(pretrained=True)
self.features = vgg.features
# 解码器(1x1卷积 + 上采样)
self.fc6 = nn.Conv2d(512, 4096, 7)
self.fc7 = nn.Conv2d(4096, 4096, 1)
self.score = nn.Conv2d(4096, num_classes, 1)
# 上采样(双线性插值或转置卷积)
self.upsample = nn.ConvTranspose2d(
num_classes, num_classes, 64, stride=32, padding=16
)
def forward(self, x):
x = self.features(x)
x = F.relu(self.fc6(x))
x = F.relu(self.fc7(x))
x = self.score(x)
x = self.upsample(x)
return x📸 U-Net就像"压缩-解压"照片,但有"小抄"
编码器(下采样):把高清图压缩成低分辨率
- 就像把4K视频压缩成480p,丢失了很多细节
- 但换来了"全局理解"——知道图里有什么
解码器(上采样):把低分辨率还原成高清
- 就像把480p放大回4K,但怎么恢复丢失的细节?
Skip Connection(跳跃连接):这是U-Net的精髓!
- 编码器每一层都把原始细节"抄一份"传给解码器
- 解码器不用完全靠猜,有"小抄"可以参考!
- 所以U-Net能恢复精细的边缘和细节
编码器 解码器
│ │
Input ─────────────────────────────► Output
│ ↑
▼ │
┌─────┐ Skip Connection ┌─────┐
│Conv │ ─────────────────────► │Conv │
│Block│ │Block│
└──┬──┘ └──┬──┘
│ ↑
▼ │
┌─────┐ ┌─────┐
│Pool │ │ Up │
└──┬──┘ └──┬──┘
│ │
▼ │
┌─────┐ Skip Connection ┌─────┐
│Conv │ ─────────────────────► │Conv │
│Block│ │Block│
└──┬──┘ └──┬──┘
│ ↑
▼ │
┌─────┐ ┌─────┐
│Pool │ │ Up │
└──┬──┘ └──┬──┘
│ │
└───────────► Bottleneck ───────┘
class DoubleConv(nn.Module):
"""(Conv -> BN -> ReLU) × 2"""
def __init__(self, in_channels, out_channels):
super().__init__()
self.double_conv = nn.Sequential(
nn.Conv2d(in_channels, out_channels, 3, padding=1),
nn.BatchNorm2d(out_channels),
nn.ReLU(inplace=True),
nn.Conv2d(out_channels, out_channels, 3, padding=1),
nn.BatchNorm2d(out_channels),
nn.ReLU(inplace=True)
)
def forward(self, x):
return self.double_conv(x)
class UNet(nn.Module):
def __init__(self, in_channels=3, num_classes=1):
super().__init__()
# 编码器
self.enc1 = DoubleConv(in_channels, 64)
self.enc2 = DoubleConv(64, 128)
self.enc3 = DoubleConv(128, 256)
self.enc4 = DoubleConv(256, 512)
self.pool = nn.MaxPool2d(2)
# 瓶颈层
self.bottleneck = DoubleConv(512, 1024)
# 解码器
self.up4 = nn.ConvTranspose2d(1024, 512, 2, stride=2)
self.dec4 = DoubleConv(1024, 512) # 512 + 512 skip connection
self.up3 = nn.ConvTranspose2d(512, 256, 2, stride=2)
self.dec3 = DoubleConv(512, 256)
self.up2 = nn.ConvTranspose2d(256, 128, 2, stride=2)
self.dec2 = DoubleConv(256, 128)
self.up1 = nn.ConvTranspose2d(128, 64, 2, stride=2)
self.dec1 = DoubleConv(128, 64)
self.out_conv = nn.Conv2d(64, num_classes, 1)
def forward(self, x):
# 编码
e1 = self.enc1(x)
e2 = self.enc2(self.pool(e1))
e3 = self.enc3(self.pool(e2))
e4 = self.enc4(self.pool(e3))
# 瓶颈
b = self.bottleneck(self.pool(e4))
# 解码(带Skip Connection)
d4 = self.dec4(torch.cat([self.up4(b), e4], dim=1))
d3 = self.dec3(torch.cat([self.up3(d4), e3], dim=1))
d2 = self.dec2(torch.cat([self.up2(d3), e2], dim=1))
d1 = self.dec1(torch.cat([self.up1(d2), e1], dim=1))
return self.out_conv(d1)🕳️ 空洞卷积:跳着格子看
想象你在看一幅很大的画:
- 普通卷积:眼睛贴着画看,一次只能看3×3的区域
- 空洞卷积:眼睛离远点看,虽然还是选3×3个点,但每个点之间隔着一段距离
好处:
- 感受野变大了(能看到更大范围)
- 分辨率没变(不像池化那样缩小图像)
- 参数量不变(还是9个权重)
就像"站远一点看全貌,但不会漏掉细节"
# 空洞卷积:扩大感受野而不减少分辨率
atrous_conv = nn.Conv2d(256, 256, 3, padding=2, dilation=2)
# dilation=1: 普通卷积(紧挨着看) 感受野 3×3
# dilation=2: 隔1格看 感受野 5×5
# dilation=4: 隔3格看 感受野 9×9
# 🎯 适用场景:语义分割需要大感受野但又要保持高分辨率class ASPP(nn.Module):
"""空洞空间金字塔池化"""
def __init__(self, in_channels, out_channels=256):
super().__init__()
# 1x1卷积
self.conv1 = nn.Conv2d(in_channels, out_channels, 1)
# 多尺度空洞卷积
self.conv2 = nn.Conv2d(in_channels, out_channels, 3, padding=6, dilation=6)
self.conv3 = nn.Conv2d(in_channels, out_channels, 3, padding=12, dilation=12)
self.conv4 = nn.Conv2d(in_channels, out_channels, 3, padding=18, dilation=18)
# 全局平均池化
self.pool = nn.Sequential(
nn.AdaptiveAvgPool2d(1),
nn.Conv2d(in_channels, out_channels, 1)
)
# 融合
self.project = nn.Conv2d(out_channels * 5, out_channels, 1)
def forward(self, x):
size = x.shape[2:]
feat1 = self.conv1(x)
feat2 = self.conv2(x)
feat3 = self.conv3(x)
feat4 = self.conv4(x)
feat5 = F.interpolate(self.pool(x), size=size, mode='bilinear')
out = torch.cat([feat1, feat2, feat3, feat4, feat5], dim=1)
return self.project(out)🎯 为什么需要Dice Loss?
想象你要分割医学图像中的肿瘤,但肿瘤只占图像的1%:
- 用普通的交叉熵损失,模型可能学会"全预测成背景",准确率99%但完全没用!
- Dice Loss 专门衡量"预测区域"和"真实区域"的重合程度
Dice系数的直观理解:
- 分子 = 2 × 重叠面积(预测对了的部分)
- 分母 = 预测面积 + 真实面积
- Dice = 1 表示完美重合,Dice = 0 表示完全不重合
这个指标不受类别不平衡影响,因为它只关注"目标区域预测得准不准"
# Dice Loss(处理类别不平衡)
class DiceLoss(nn.Module):
def __init__(self, smooth=1.0):
super().__init__()
self.smooth = smooth
def forward(self, pred, target):
pred = torch.sigmoid(pred)
intersection = (pred * target).sum()
union = pred.sum() + target.sum()
dice = (2 * intersection + self.smooth) / (union + self.smooth)
return 1 - dice
# 组合损失
class CombinedLoss(nn.Module):
def __init__(self, alpha=0.5):
super().__init__()
self.bce = nn.BCEWithLogitsLoss()
self.dice = DiceLoss()
self.alpha = alpha
def forward(self, pred, target):
return self.alpha * self.bce(pred, target) + (1 - self.alpha) * self.dice(pred, target)- 理解语义分割和实例分割的区别
- 手写实现U-Net网络
- 理解Skip Connection的作用
- 完成医学图像分割任务
📸 数据增强:让一张照片变出"一百张"
问题:深度学习需要大量数据,但收集和标注很贵!
解决方案:对已有图片做各种变换,"变"出更多训练数据
- 翻转:猫朝左和朝右都是猫
- 旋转:斜着的猫还是猫
- 缩放:远处的猫和近处的猫都是猫
- 颜色变化:不同光线下的猫还是猫
本质:教会模型"不变性"——无论图片怎么变,它还是那只猫!
import torchvision.transforms as T
# 训练时的数据增强
train_transform = T.Compose([
T.RandomResizedCrop(224), # 随机裁剪并缩放
T.RandomHorizontalFlip(), # 水平翻转
T.RandomRotation(15), # 随机旋转
T.ColorJitter( # 颜色抖动
brightness=0.2,
contrast=0.2,
saturation=0.2,
hue=0.1
),
T.ToTensor(),
T.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])
])
# 验证/测试时
val_transform = T.Compose([
T.Resize(256),
T.CenterCrop(224),
T.ToTensor(),
T.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])
])🍹 高级数据增强:混合调酒师
Mixup(像调鸡尾酒):
- 把两张图片"按比例混合":0.7×猫图 + 0.3×狗图 = 一张"半透明重叠"的图
- 标签也混合:[0.7猫, 0.3狗]
- 教模型:"这个看起来像猫但有点像狗的东西,70%是猫"
CutMix(像拼贴画):
- 从狗图上裁一块,贴到猫图上
- 标签按面积比例设置
- 比Mixup更"真实",因为不是半透明叠加
效果:模型更健壮,不会因为"从没见过这种组合"就崩溃
def mixup(x, y, alpha=1.0):
"""Mixup数据增强:两张图按比例混合"""
lam = np.random.beta(alpha, alpha)
batch_size = x.size(0)
index = torch.randperm(batch_size)
mixed_x = lam * x + (1 - lam) * x[index, :]
y_a, y_b = y, y[index]
return mixed_x, y_a, y_b, lam
# 训练时
for x, y in dataloader:
x, y_a, y_b, lam = mixup(x, y)
output = model(x)
loss = lam * criterion(output, y_a) + (1 - lam) * criterion(output, y_b)def cutmix(x, y, alpha=1.0):
"""CutMix数据增强"""
lam = np.random.beta(alpha, alpha)
batch_size = x.size(0)
index = torch.randperm(batch_size)
# 计算裁剪区域
W, H = x.size(3), x.size(2)
cut_rat = np.sqrt(1 - lam)
cut_w = int(W * cut_rat)
cut_h = int(H * cut_rat)
cx = np.random.randint(W)
cy = np.random.randint(H)
bbx1 = np.clip(cx - cut_w // 2, 0, W)
bby1 = np.clip(cy - cut_h // 2, 0, H)
bbx2 = np.clip(cx + cut_w // 2, 0, W)
bby2 = np.clip(cy + cut_h // 2, 0, H)
# 裁剪并粘贴
x[:, :, bby1:bby2, bbx1:bbx2] = x[index, :, bby1:bby2, bbx1:bbx2]
# 调整lambda
lam = 1 - ((bbx2 - bbx1) * (bby2 - bby1) / (W * H))
return x, y, y[index], lamimport albumentations as A
from albumentations.pytorch import ToTensorV2
# 强大的数据增强库
transform = A.Compose([
A.RandomResizedCrop(224, 224),
A.HorizontalFlip(p=0.5),
A.ShiftScaleRotate(p=0.5),
A.OneOf([
A.GaussNoise(),
A.GaussianBlur(),
A.MotionBlur(),
], p=0.3),
A.OneOf([
A.RandomBrightnessContrast(),
A.HueSaturationValue(),
], p=0.3),
A.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),
ToTensorV2()
])
# 使用
augmented = transform(image=image)
image = augmented['image']🎓 迁移学习:站在巨人的肩膀上
问题:从零训练一个好模型需要百万张图片,但你只有1000张?
解决方案:借用别人在大数据集上训练好的模型!
三种策略:
策略 做法 适用场景 比喻 特征提取 冻结预训练层,只训练最后一层 数据很少(<1000) 🎯 用别人的望远镜,只换目镜 微调 解冻部分层,用小学习率训练 数据中等(1000-10000) 🔧 在别人作品基础上修改 分层学习率 底层小学习率,顶层大学习率 数据较多 🎨 底层保留,顶层重画 为什么有效? CNN底层学习的是"边缘、纹理、颜色"等通用特征,这些在任何图像任务中都有用!
import torchvision.models as models
# 策略1: 特征提取(冻结预训练层)🎯
def create_feature_extractor(num_classes):
model = models.resnet50(pretrained=True)
# 冻结所有参数(不更新预训练的权重)
for param in model.parameters():
param.requires_grad = False
# 替换最后一层
model.fc = nn.Linear(model.fc.in_features, num_classes)
return model
# 策略2: 微调(解冻部分层)
def create_finetuned_model(num_classes, unfreeze_from='layer4'):
model = models.resnet50(pretrained=True)
# 冻结所有参数
for param in model.parameters():
param.requires_grad = False
# 解冻指定层
unfreeze = False
for name, child in model.named_children():
if name == unfreeze_from:
unfreeze = True
if unfreeze:
for param in child.parameters():
param.requires_grad = True
# 替换最后一层
model.fc = nn.Linear(model.fc.in_features, num_classes)
return model
# 策略3: 使用不同学习率
def get_optimizer(model, lr=0.001, pretrained_lr=0.0001):
pretrained_params = []
new_params = []
for name, param in model.named_parameters():
if 'fc' in name: # 新添加的层
new_params.append(param)
else:
pretrained_params.append(param)
optimizer = torch.optim.Adam([
{'params': pretrained_params, 'lr': pretrained_lr},
{'params': new_params, 'lr': lr}
])
return optimizer- 掌握常用数据增强技术
- 理解Mixup和CutMix的原理
- 掌握迁移学习的几种策略
- 使用预训练模型完成分类任务
目标:使用ResNet在CIFAR-10上达到90%+准确率
要求:
- 实现ResNet-18网络
- 使用数据增强
- 训练并调优模型
- 分析错误案例
目标:使用YOLOv8训练自定义目标检测模型
要求:
- 收集并标注自定义数据集(如口罩检测)
- 配置YOLOv8训练
- 评估模型性能
- 部署推理服务
目标:使用U-Net完成医学图像分割
要求:
- 实现U-Net网络
- 在Kaggle数据集上训练
- 使用Dice Loss
- 可视化分割结果
目标:使用ImageNet预训练模型完成小样本分类
要求:
- 使用预训练ResNet/EfficientNet
- 在小数据集(<1000张)上微调
- 对比不同微调策略效果
- 分析特征可视化
完成以下任务后,进入阶段3.6(生成模型)或阶段四(NLP基础):
-
CNN深入
- 理解卷积运算和感受野
- 手写实现2D卷积
- 理解BatchNorm作用
-
经典架构
- 理解ResNet残差连接
- 能使用预训练模型
- 了解轻量化网络设计
-
目标检测
- 理解IoU和NMS
- 理解Anchor机制
- 使用YOLOv8完成检测任务
-
图像分割
- 理解语义分割原理
- 实现U-Net网络
- 理解Dice Loss
-
实践项目
- 完成CIFAR-10分类(准确率90%+)
- 完成自定义目标检测或图像分割项目
- CV方向:进入阶段3.6:生成模型
- NLP方向:进入阶段四:NLP基础
- 全栈路径:建议先完成CV方向,再学习NLP方向