一、卷积神经网络概念卷积卷积是一种数学运算在深度学习尤其是计算机视觉中特指二维互相关运算通常简称为卷积。其基本操作是用一个小的卷积核filter/kernel在输入图像上滑动对每个位置上的重叠区域做逐元素相乘再求和得到输出特征图中的一个值。可以把卷积核想象成一个“模式探测器”或“特征筛选器”。当卷积核滑动到某个位置时它与图像局部区域的相似度越高输出值就越大。例如一个检测垂直边缘的卷积核会在图像的垂直边缘处产生较大的响应。数学表达式二维离散卷积其中 I是输入图像K是卷积核。实际在 CNN 中通常做的是互相关不翻转核但本质思想一致。为什么要用卷积在传统的全连接神经网络中处理图像时会将每个像素作为独立特征输入导致参数爆炸且无法利用图像的局部结构。卷积的引入主要解决了以下几个关键问题1.局部连接Local Connectivity图像中近处的像素之间关联性强远处的像素关联性弱。卷积核只关注局部区域例如 3×3 或 5×5这与人类视觉系统由局部到整体的认知方式一致。➜好处显著减少连接数量避免对远距离无关像素的冗余建模。2.参数共享Weight Sharing同一个卷积核在图像的所有位置使用相同的权重。这意味着一个边缘检测器可以同时检测图像中任何位置的边缘。➜好处参数量不随图像尺寸增大而线性增长。例如一张 1000×1000 的图像用全连接层需要百万级参数而一个 3×3 卷积核仅需 9 个参数不考虑偏置和通道数。3.平移不变性Translation Invariance由于参数共享卷积对图像中的模式具有位置无关性——无论猫的耳朵出现在左上角还是右下角同一个卷积核都能识别出来。这大大增强了模型的泛化能力。4.捕获层次化特征Hierarchical Features浅层卷积核学习低级特征边缘、颜色、纹理深层卷积核将低级特征组合成复杂的高级语义眼睛、鼻子、物体类别。这种层次结构是深度卷积神经网络成功的关键。卷积——多通道用不同的卷积核提取不同的特征这样的效果更好每一个卷积核都有一个偏执池化Pooling池化是一种下采样操作通常紧跟在卷积层之后。它对特征图的每个小区域如 2×2进行聚合统计常见的有最大池化Max Pooling取区域内的最大值MaxPool2d(kernel_size2, stride2)平均池化Average Pooling取区域内的平均值全局平均池化Global Average Pooling对整张特征图取平均常用于分类器前池化没有需要学习的参数只是固定计算。池化的核心作用降低空间尺寸例如 2×2 池化将特征图的高和宽减半从而大幅减少后续层的计算量和参数量。增强平移不变性即使目标在图像中轻微移动池化后的最大值或平均值可能保持不变使模型对微小位移更加鲁棒。增大感受野池化后每个输出点对应于输入中一个更大的区域有助于捕获更宏观的特征。抑制噪声最大池化保留最强响应剔除弱噪声平均池化则平滑局部变化。卷积与池化的典型配合使用方式在经典 CNN 架构如 LeNet、AlexNet、VGG中通常的范式是输入 → [卷积层 激活函数] → 池化层 → [卷积层 激活函数] → 池化层 → ... → 全连接层具体设计规律阶段层组合作用特征提取阶段卷积 → 激活 (ReLU) → 池化逐级提取局部到全局特征同时逐步降维分类阶段全局平均池化 或 展平 全连接层将空间特征聚合为类别分数几个关键点池化通常不跨通道每个通道独立做池化通道数不变。池化 stride 一般等于 kernel_size例如 2×2 池化步长2避免重叠。现代趋势一些网络如 ResNet、DenseNet使用步长为 2 的卷积替代池化来降采样既能降维又可学习下采样模式。但池化依然因为简单有效而广泛使用。实战1卷积神经网络1.数据准备import matplotlib as mpl import matplotlib.pyplot as plt %matplotlib inline import numpy as np import sklearn import pandas as pd import os import sys import time from tqdm.auto import tqdm import torch import torch.nn as nn import torch.nn.functional as F print(sys.version_info) for module in mpl, np, pd, sklearn, torch: print(module.__name__, module.__version__) device torch.device(cuda:0) if torch.cuda.is_available() else torch.device(cpu) print(device) seed 42 #%% md ## 数据准备 #%% from torchvision import datasets from torchvision.transforms import ToTensor from torch.utils.data import random_split # fashion_mnist图像分类数据集 train_ds datasets.FashionMNIST( rootdata, trainTrue, downloadTrue, transformToTensor() ) test_ds datasets.FashionMNIST( rootdata, trainFalse, downloadTrue, transformToTensor() ) # torchvision 数据集里没有提供训练集和验证集的划分 # 这里用 random_split 按照 11 : 1 的比例来划分数据集 train_ds, val_ds random_split(train_ds, [55000, 5000], torch.Generator().manual_seed(seed)) from torchvision.transforms import Normalize # 遍历train_ds得到每张图片计算每个通道的均值和方差 def cal_mean_std(ds): mean 0. std 0. for img, _ in ds: mean img.mean(dim(1, 2)) std img.std(dim(1, 2)) mean / len(ds) std / len(ds) return mean, std # print(cal_mean_std(train_ds)) # 0.2860 0.3205 transforms nn.Sequential( Normalize([0.2856], [0.3202]) ) # 对每个通道进行标准化 from torch.utils.data.dataloader import DataLoader batch_size 32 # 从数据集到dataloader train_loader DataLoader(train_ds, batch_sizebatch_size, shuffleTrue, num_workers4) val_loader DataLoader(val_ds, batch_sizebatch_size, shuffleFalse, num_workers4) test_loader DataLoader(test_ds, batch_sizebatch_size, shuffleFalse, num_workers4)2.模型构建class CNN(nn.Module): def __init__(self, activationrelu): super(CNN, self).__init__() self.activation F.relu if activation relu else F.selu #输入通道数图片是灰度图所以是1图片是彩色图就是3输出通道数就是卷积核的个数32,1,28,28 self.conv1 nn.Conv2d(in_channels1, out_channels32, kernel_size3, padding1) #输入x(32,32,28,28) 输出x(32,32,28,28) self.conv2 nn.Conv2d(in_channels32, out_channels32, kernel_size3, padding1) self.pool nn.MaxPool2d(2, 2) #池化核大小为22*2步长为2 self.conv3 nn.Conv2d(in_channels32, out_channels64, kernel_size3, padding1) self.conv4 nn.Conv2d(in_channels64, out_channels64, kernel_size3, padding1) self.conv5 nn.Conv2d(in_channels64, out_channels128, kernel_size3, padding1) self.conv6 nn.Conv2d(in_channels128, out_channels128, kernel_size3, padding1) self.flatten nn.Flatten() # input shape is (28, 28, 1) so the fc1 layer in_features is 128 * 3 * 3 self.fc1 nn.Linear(128 * 3 * 3, 128) self.fc2 nn.Linear(128, 10) #输出尺寸32,10 self.init_weights() def init_weights(self): 使用 xavier 均匀分布来初始化全连接层、卷积层的权重 W for m in self.modules(): if isinstance(m, (nn.Linear, nn.Conv2d)): nn.init.xavier_uniform_(m.weight) nn.init.zeros_(m.bias) def forward(self, x): act self.activation x self.pool(act(self.conv2(act(self.conv1(x))))) # 1 * 28 * 28 - 32 * 14 * 14 print(x.shape) x self.pool(act(self.conv4(act(self.conv3(x))))) # 32 * 14 * 14 - 64 * 7 * 7 print(x.shape) x self.pool(act(self.conv6(act(self.conv5(x))))) # 64 * 7 * 7 - 128 * 3 * 3 print(x.shape) x self.flatten(x) # 128 * 3 * 3 -1152 x act(self.fc1(x)) # 1152 - 128 x self.fc2(x) # 128 - 10 return x for idx, (key, value) in enumerate(CNN().named_parameters()): print(f{key}\tparamerters num: {np.prod(value.shape)}) # 打印模型的参数信息super(CNN, self).__init__()继承nn.Module确保模型能正常注册参数。self.activation F.relu if activation relu else F.selu提供两种非线性激活函数ReLU默认或 SELU。注意F.selu是 Scaled Expontial Linear Unitne自带自归一化性质与AlphaDropout搭配效果更好。在self.activation F.relu中我们把函数对象赋值给self.activation这样在forward中可以通过self.activation(x)来调用。如果写成F.selu()那就变成了立即调用该函数返回的是调用结果通常是一个张量而不是函数对象后续无法再作为激活函数使用。F.reluvsnn.ReLU的区别特性torch.nn.ReLUtorch.nn.functional.relu类型模块类函数使用方式实例化后调用self.relu nn.ReLU()然后x self.relu(x)直接调用x F.relu(x)是否需要存储状态是无参数但需要占位否在nn.Sequential中可以直接添加需要包装成 lambda 或使用torch.nn.F不便适用场景需要作为网络层便于nn.Sequential和nn.ModuleList管理简单的函数调用更轻量卷积层self.conv1 nn.Conv2d(in_channels1, out_channels32, kernel_size3, padding1) #输入x(32,32,28,28) 输出x(32,32,28,28)in_channels1,输入的通道数为1out_channels32,卷积核的个数同时也是输出的通道数为32kernel_size3,卷积核大小为3padding1 在输入特征的四周补一圈0这一层参数的个数32卷积核的个数*3*3卷积核的大小卷积核张量形状(32, 1, 3, 3)32个卷积核每个核的深度是1因为输入只有1个通道得到1个(32,28,28)。2. 第二个卷积层in32, out32self.conv2 nn.Conv2d(in_channels32, out_channels32, kernel_size3, padding1)如果输入是RGB彩色图in_channels3而你希望输出32个通道self.conv1 nn.Conv2d(in_channels3, out_channels32, kernel_size3, padding1)卷积核张量形状(32, 3, 3, 3)32个卷积核每个卷积核都是3通道的分别处理R、G、B三个通道然后将结果相加得到该输出通道的一个值。def __init__(self, activationrelu): super(CNN, self).__init__() self.activation F.relu if activation relu else F.selu #输入通道数图片是灰度图所以是1图片是彩色图就是3输出通道数就是卷积核的个数32,1,28,28 self.conv1 nn.Conv2d(in_channels1, out_channels32, kernel_size3, padding1) #输入x(32,32,28,28) 输出x(32,32,28,28) self.conv2 nn.Conv2d(in_channels32, out_channels32, kernel_size3, padding1) self.pool nn.MaxPool2d(2, 2) #池化核大小为22*2步长为2 self.conv3 nn.Conv2d(in_channels 32, out_channels64, kernel_size3, padding1) self.conv4 nn.Conv2d(in_channels64, out_channels64, kernel_size3, padding1) self.conv5 nn.Conv2d(in_channels64, out_channels128, kernel_size3, padding1) self.conv6 nn.Conv2d(in_channels128, out_channels128, kernel_size3, padding1) self.flatten nn.Flatten() # input shape is (28, 28, 1) so the fc1 layer in_features is 128 * 3 * 3 self.fc1 nn.Linear(128 * 3 * 3, 128) self.fc2 nn.Linear(128, 10) #输出尺寸32,10 self.init_weights()所有卷积层都使用kernel_size3, padding1这样卷积前后尺寸不变输入 H,W 输出 H,W。通道数逐步增加1 → 32 → 32 → 64 → 64 → 128 → 128符合“浅层少通道、深层多通道”的设计模式。池化层self.pool nn.MaxPool2d(2, 2) #池化核大小为22*2步长为2最大池化核大小 2×2步长 2。将特征图的高和宽减半例如 28→14, 14→7, 7→3向下取整。展平层和全连接层self.flatten nn.Flatten() self.fc1 nn.Linear(128 * 3 * 3, 128) # 输入 1152输出 128 self.fc2 nn.Linear(128, 10) # 输出10个类别初始化方法def init_weights(self): for m in self.modules(): if isinstance(m, (nn.Linear, nn.Conv2d)): nn.init.xavier_uniform_(m.weight) nn.init.zeros_(m.bias)遍历模型所有模块对Linear和Conv2d层使用Xavier 均匀分布初始化权重xavier_uniform_。偏置初始化为 0。注意Xavier 初始化更适合饱和激活函数如 tanh这里使用了 ReLU/SELU严格来说 He 初始化可能更优但这属于实验调优范畴。3. 前向传播方法forward(self, x)def forward(self, x): act self.activation x self.pool(act(self.conv2(act(self.conv1(x))))) # 1×28×28 → 32×14×14 x self.pool(act(self.conv4(act(self.conv3(x))))) # 32×14×14 → 64×7×7 x self.pool(act(self.conv6(act(self.conv5(x))))) # 64×7×7 → 128×3×3 x self.flatten(x) # 128×3×3 → 1152 x act(self.fc1(x)) # 1152 → 128 x self.fc2(x) # 128 → 10 return x形状变化详解以单样本为例忽略 batch 维度实际输入: (batch, 1, 28, 28)实际输出: (batch, 32, 28, 28)因为我们关注的是一个样本的形状的变化batch指的是样本个数操作输入形状输出形状备注输入(1, 28, 28)灰度图conv1(1,28,28)(32,28,28)padding1 保持尺寸act(ReLU)(32,28,28)(32,28,28)非线性conv2(32,28,28)(32,28,28)保持尺寸act(32,28,28)(32,28,28)pool(2,2)(32,28,28)(32,14,14)尺寸减半conv3(32,14,14)(64,14,14)通道数增加act(64,14,14)(64,14,14)conv4(64,14,14)(64,14,14)act(64,14,14)(64,14,14)pool(64,14,14)(64,7,7)尺寸减半conv5(64,7,7)(128,7,7)act(128,7,7)(128,7,7)conv6(128,7,7)(128,7,7)act(128,7,7)(128,7,7)pool(128,7,7)(128,3,3)7/23.5 向下取整 → 3flatten(128,3,3)(1152,)fc1 act(1152,)(128,)fc2(128,)(10,)特别注意第三次池化后尺寸从 7×7 变为 3×3是因为MaxPool2d(2,2)对奇数尺寸的向下取整效果(7 - 2)/2 1 3.5 → 3。因此展平长度 128 × 3 × 3 1152与fc1的定义一致。PyTorch 的nn.Flatten()默认从第1维通道维开始展平而第0维batch 维保持不变。二、深度可分离卷积第一层5*5被3*3的卷积核提取之后变成第二层第二层3*3被3*3的卷积核提取之后变成第一层第三层最上面1*1什么是深度可分离卷积深度可分离卷积Depthwise Separable Convolution是一种分解式的卷积操作它将标准卷积拆分为两个独立的步骤深度卷积Depthwise Convolution——每个输入通道单独做空间卷积每个通道单独用一个卷积核卷积之后不改变通道数逐点卷积Pointwise Convolution—— 用 1×1 卷积混合通道有多个卷积核但每个通道共用一个卷积核卷积之后改变通道数这种分解可以在保持相近精度的前提下大幅减少模型的参数量和计算量是轻量级神经网络如 MobileNet、Xception的基石。为什么要用深度可分离卷积标准卷积在提取特征时既做空间聚合通过 3×3、5×5 等卷积核又做通道融合不同通道的输出相加。这两件事其实可以解耦。深度可分离卷积的核心思想就是先单独处理每个空间位置深度卷积再通过 1×1 卷积融合通道信息。这样做的好处参数量减少约 8~9 倍对于 3×3 卷积计算量同样大幅下降适合移动端、嵌入式等资源受限场景实战2深度可分离卷积1.数据准备import matplotlib as mpl import matplotlib.pyplot as plt %matplotlib inline import numpy as np import sklearn import pandas as pd import os import sys import time from tqdm.auto import tqdm import torch import torch.nn as nn import torch.nn.functional as F print(sys.version_info) for module in mpl, np, pd, sklearn, torch: print(module.__name__, module.__version__) device torch.device(cuda:0) if torch.cuda.is_available() else torch.device(cpu) print(device) seed 42 ## 数据准备 from torchvision import datasets from torchvision.transforms import ToTensor from torch.utils.data import random_split # fashion_mnist图像分类数据集 train_ds datasets.FashionMNIST( rootdata, trainTrue, downloadTrue, transformToTensor() ) test_ds datasets.FashionMNIST( rootdata, trainFalse, downloadTrue, transformToTensor() ) # torchvision 数据集里没有提供训练集和验证集的划分 # 这里用 random_split 按照 11 : 1 的比例来划分数据集 train_ds, val_ds random_split(train_ds, [55000, 5000], torch.Generator().manual_seed(seed)) from torchvision.transforms import Normalize # 遍历train_ds得到每张图片计算每个通道的均值和方差 def cal_mean_std(ds): mean 0. std 0. for img, _ in ds: mean img.mean(dim(1, 2)) std img.std(dim(1, 2)) mean / len(ds) std / len(ds) return mean, std # print(cal_mean_std(train_ds)) # 0.2860 0.3205 transforms nn.Sequential( Normalize([0.2856], [0.3202]) ) from torch.utils.data.dataloader import DataLoader batch_size 32 # 从数据集到dataloader train_loader DataLoader(train_ds, batch_sizebatch_size, shuffleTrue, num_workers4) val_loader DataLoader(val_ds, batch_sizebatch_size, shuffleFalse, num_workers4) test_loader DataLoader(test_ds, batch_sizebatch_size, shuffleFalse, num_workers4)2.模型构建# 定义深度可分离卷积层torch没有实现tf有实现 class DepthWiseConv2d(nn.Module): def __init__(self, in_channels, out_channels, kernel_size, stride1, padding0, biasTrue): super(DepthWiseConv2d, self).__init__() #这里写为super().__init__()等价的 self.depthwise_conv nn.Conv2d(in_channels, in_channels, kernel_size, stride, padding, groupsin_channels, biasFalse) #groups参数表示一个卷积核的每个通道分别进行运算 self.pointwise_conv nn.Conv2d(in_channels, out_channels, 1, 1, 0, biasbias) def forward(self, x): x self.depthwise_conv(x) x self.pointwise_conv(x) return x class CNN(nn.Module): def __init__(self, activationrelu): super(CNN, self).__init__() self.activation F.relu if activation relu else F.selu self.conv1 nn.Conv2d(in_channels1, out_channels32, kernel_size3, paddingsame) self.conv2 DepthWiseConv2d(in_channels32, out_channels32, kernel_size3, paddingsame) self.pool nn.MaxPool2d(2, 2) self.conv3 DepthWiseConv2d(in_channels32, out_channels64, kernel_size3, paddingsame) self.conv4 DepthWiseConv2d(in_channels64, out_channels64, kernel_size3, paddingsame) self.conv5 DepthWiseConv2d(in_channels64, out_channels128, kernel_size3, paddingsame) self.conv6 DepthWiseConv2d(in_channels128, out_channels128, kernel_size3, paddingsame) self.flatten nn.Flatten() # input shape is (28, 28, 1) so the fc1 layer in_features is 128 * 3 * 3 self.fc1 nn.Linear(128 * 3 * 3, 128) self.fc2 nn.Linear(128, 10) self.init_weights() def init_weights(self): 使用 xavier 均匀分布来初始化全连接层、卷积层的权重 W for m in self.modules(): if isinstance(m, (nn.Linear, nn.Conv2d)): nn.init.xavier_uniform_(m.weight) if m.bias is not None: nn.init.zeros_(m.bias) def forward(self, x): act self.activation #x --(batch_size, 1, 28, 28) x self.pool(act(self.conv2(act(self.conv1(x))))) # (batch_size, 32, 14, 14) x self.pool(act(self.conv4(act(self.conv3(x))))) # (batch_size, 64, 7, 7) x self.pool(act(self.conv6(act(self.conv5(x))))) # (batch_size, 128, 3, 3) x self.flatten(x) # (batch_size, 128 * 3 * 3) x act(self.fc1(x)) # (batch_size, 128) x self.fc2(x) # (batch_size, 10) return x for idx, (key, value) in enumerate(CNN().named_parameters()): print(f{key}\tparamerters num: {np.prod(value.shape)})def __init__(self, in_channels, out_channels, kernel_size, stride1, padding0, biasTrue): super(DepthWiseConv2d, self).__init__() # 第一步深度卷积Depthwise Convolution self.depthwise_conv nn.Conv2d( in_channels, in_channels, kernel_size, stride, padding, groupsin_channels, # -- 这是关键参数 biasFalse ) # 第二步逐点卷积Pointwise Convolution self.pointwise_conv nn.Conv2d( in_channels, out_channels, 1, 1, 0, biasbias )第一步深度卷积Depthwise Convolution关键参数groupsin_channels的含义在普通卷积中groups1默认意味着输入的所有通道被一个卷积核求出的值被加权求和。当groupsin_channels时输入通道被完全分组每个通道独立享有自己的卷积核。即“每个通道单独做卷积互不干扰”。第二步逐点卷积Pointwise Convolution