ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

手算卷积入门:从6×6图像到CNN每一层的空间逻辑

手算卷积入门:从6×6图像到CNN每一层的空间逻辑 1. 这不是“背概念”而是搞懂CNN里每一层到底在干啥——从手算卷积开始你翻过十几份《机器学习入门》讲义看到“全连接层”和“卷积层”的对比表格上面写着“参数量少”“局部感受野”“权值共享”——但这些词像贴在玻璃上的标签你看得见却摸不着它怎么动、怎么算、怎么影响最终结果。我带过三届本科生做图像分类项目最常听到的困惑不是“什么是反向传播”而是“为什么我改了卷积核大小准确率突然掉5%”“为什么padding1时输出尺寸没变但模型反而学得更慢”“明明输入是3×224×224为什么第一个卷积层输出是32×224×224而不是32×222×222”这些问题没有一个能靠背定义解决。这节内容我们不讲PPT里的结构图不列公式推导就用一支笔、一张纸、一个3×3的卷积核、一张6×6的灰度图从零手算一遍卷积运算到底发生了什么填充padding是在补哪几行哪几列步幅stride怎么决定输出尺寸三维数据比如RGB图上卷积核怎么滑动多个卷积核输出的特征图为什么能堆叠成通道维度批处理batch又如何让整个计算过程变成矩阵乘法全连接层和卷积层的本质区别不在名字而在它们对数据空间关系的“态度”——前者把图像当一串毫无关联的像素数字后者则坚信相邻像素之间有不可割裂的语义联系。这种信念直接决定了CNN能识别出猫的耳朵而全连接网络只能记住“第12789个像素亮、第12790个像素暗”这种毫无泛化能力的模式。如果你正准备西电或山大机器学习期末考试或者刚跑通CSND上的人脸识别开源项目却卡在模型结构调整上这篇就是为你写的。它不教你“怎么调参”而是让你看清参数背后的空间逻辑——这才是真正入门的起点。2. 全连接层 vs 卷积层两种世界观的碰撞2.1 全连接层把世界拍扁再数点先看全连接层Fully Connected LayerFC。假设你有一张28×28的手写数字图片MNIST共784个像素。全连接层第一件事就是把这张二维图像“拍扁”成一个784维的向量。然后它为这个向量中的每一个元素都分配一个独立的权重去连接下一层的每一个神经元。如果下一层有128个神经元那这一层就要维护784×12899,328个参数。每个参数都是独立训练的彼此之间没有任何约束。提示你可以把全连接层想象成一个极度近视的裁判——他只盯着单个像素点看完全不知道左边那个像素是鼻子右边那个是眼睛。他判断“这是数字7”全靠统计过去1000张7的图片里第342号像素平均亮度是0.82第567号是0.15……于是他记下这两条规则。但一旦遇到一张旋转了15度的7或者墨水晕开导致第342号像素变暗这套规则立刻失效。因为它的知识是“离散点”的集合不是“空间结构”的理解。这就是全连接层的致命缺陷参数爆炸和空间不变性缺失。参数爆炸意味着你需要海量数据去填满这些参数否则极易过拟合空间不变性缺失意味着模型无法理解“猫耳朵无论出现在左上角还是右下角都是耳朵”这种基本常识。这也是为什么纯FC网络在ImageNet上连10%准确率都难破——它根本没学会“看图”只是在记“像素组合”。2.2 卷积层用同一把尺子丈量所有角落卷积层彻底换了一种思路。它不给每个像素配独立权重而是设计一把“小尺子”——这就是卷积核Kernel/Filter比如3×3大小。这把尺子只含9个可学习参数。它从图像左上角开始与对应区域的9个像素做逐元素相乘再求和得到一个标量输出然后它向右平移一步步幅stride1再算一次走到尽头后向下平移一步继续……直到扫完整张图。关键来了这把尺子在整个图像上是“共享”的。左上角用的3×3参数右下角用的还是同一组9个参数。这意味着模型学到的不是“某个位置的纹理”而是“某种通用的局部模式”比如边缘、线条、斑点。无论猫的耳朵出现在图中哪个位置只要它包含相似的局部结构这把尺子就能检测出来——这就是平移不变性Translation Invariance的来源。注意卷积核的“共享”不是人为规定而是由问题本质决定的。图像的底层规律如边缘检测在空间上是重复出现的强行让不同位置用不同参数等于假设“左上角的边缘和右下角的边缘是两种完全不同的东西”这违背视觉感知的基本原理。卷积操作本质上是对图像进行一种受控的、局部的、参数共享的线性变换。2.3 参数量对比一个数字说明一切我们来算一笔硬账。还是那张28×28的图输入通道C_in1灰度图输出通道C_out32即用32把不同的“尺子”去检测32种不同模式。全连接层方案输入784维 → 输出32维参数量 784 × 32 25,088卷积层方案3×3卷积核 × 1输入通道 × 32输出通道 288差距接近100倍。而且这个差距会随着图像变大急剧扩大。一张224×224的RGB图3通道全连接层输入维度是224×224×3150,528哪怕只连到64个神经元参数也高达9.6M而一个3×3卷积层参数量仅为3×3×3×641,728。卷积层的参数效率不是优化技巧而是对图像数据物理本质的尊重。2.4 感受野卷积层的“视野”如何层层扩大全连接层的每个神经元感受野Receptive Field就是整张图——它“看见”全部却“理解”无能。卷积层则不同它的感受野是逐步构建的。第一层卷积核3×3感受野就是3×3第二层再用3×3卷积核作用于第一层的输出特征图其感受野就变成了5×5第三层叠加后感受野扩大到7×7……以此类推。实操心得我在调试LeNet-5时发现如果第一层卷积核太大比如7×7虽然单层感受野大但丢失了精细纹理信息导致对数字笔画粗细变化不敏感而用两个3×3卷积层堆叠总参数更少感受野同样达到7×7却能分层提取“边缘→线条→部件”。这就是“小核深堆叠”优于“大核浅层”的核心原因——它模拟了人类视觉皮层的层级处理机制。3. 卷积神经网络结构从LeNet-5到现代架构的演进逻辑3.1 LeNet-5教科书级的奠基结构1998Yann LeCun提出的LeNet-5至今仍是理解CNN结构的黄金模板。它处理32×32的手写数字图结构清晰Input (32×32) ↓ Conv1: 6个5×5卷积核, stride1, no padding → Output: 6×28×28 ↓ ReLU (非线性激活) ↓ Pool1: 2×2最大池化, stride2 → Output: 6×14×14 ↓ Conv2: 16个5×5卷积核, stride1, no padding → Output: 16×10×10 ↓ ReLU ↓ Pool2: 2×2最大池化, stride2 → Output: 16×5×5 ↓ FC1: 全连接层, 120个神经元 → Output: 120 ↓ ReLU ↓ FC2: 全连接层, 84个神经元 → Output: 84 ↓ Output Layer: 10个神经元 (对应0-9数字)这里的关键设计选择全是为了解决实际问题为什么Conv1用5×5当时计算资源有限5×5能在保留足够局部信息的同时控制参数量。实测发现3×3虽更省但对MNIST这种简单任务易欠拟合。为什么Pool1后尺寸减半最大池化Max Pooling不仅降维更重要的是提供平移鲁棒性——即使数字轻微移动几个像素池化后的最大值大概率不变模型判别更稳定。为什么Conv2输出16个通道第一层检测基础边缘第二层需要组合这些边缘形成更复杂模式如“L形拐角”、“十字交叉”16个核提供了足够的模式组合空间。注意LeNet-5的“C3层”其实是局部连接不是全连接这是早期为减少参数做的折中。现代CNN已普遍采用全连接卷积即每个输出通道连接所有输入通道因为GPU算力足以支撑且效果更好。3.2 AlexNet深度革命的引爆点2012AlexNet将CNN带入主流其结构颠覆了LeNet的温和路线更深8层5卷积3全连接远超LeNet的5层。更大卷积核第一层用11×11第二层用5×5——当时为弥补ReLU激活函数初期饱和问题用大核强行提取强特征。重叠池化池化步幅stride小于池化窗口pool size如3×3池化用stride2避免特征图过度缩小。Dropout在最后两个全连接层间加入Dropoutp0.5显著抑制过拟合。但AlexNet最革命性的贡献是证明了深度带来的表征能力跃迁。它在ImageNet上将错误率从26%降到16%不是靠微调而是靠堆叠更多非线性变换层。这直接催生了VGG、ResNet等更深架构。3.3 VGG与ResNet宽度、深度与残差的平衡术VGG-16用大量3×3小卷积核堆叠如13个3×3替代1个7×7参数量虽增但感受野等效、梯度更平滑、易于训练。其结构像一条直筒卷积→ReLU→卷积→ReLU→池化循环往复。ResNet-50解决深度网络梯度消失问题。它引入残差连接Skip ConnectionOutput F(x) x。当F(x)学习困难时网络可自动退化为恒等映射F(x)0保证深层信息不被破坏。ResNet的“块”Block设计让50层甚至1000层网络成为可能。实操心得我在实验室部署一个工业质检模型时用VGG-16做基线准确率92.3%换成ResNet-18后提升到94.7%但推理速度慢15%。最终选了ResNet-18的轻量化版通道数减半准确率94.1%速度反超VGG。这说明结构选择不是“越深越好”而是要匹配你的硬件GPU显存、延迟要求实时检测需50ms和数据规模小样本用浅网更稳。3.4 现代CNN的通用骨架Conv → BN → ReLU → Pool可选今天绝大多数CNN无论用于医疗影像分割还是自动驾驶感知都遵循一个稳健骨架卷积层Conv提取局部特征核大小通常3×3兼顾感受野与参数效率。批归一化BatchNorm对每一批batch数据的每个通道做归一化减均值、除标准差极大加速训练、提升稳定性。没有BNResNet根本训不动。激活函数ReLU引入非线性。ReLUf(x)max(0,x)因计算简单、缓解梯度消失成为绝对主流。池化层Pooling传统CNN必备但现代趋势是用带步幅的卷积Strided Convolution替代。例如用3×3卷积核、stride2既能降维又能学习特征比固定池化更灵活。这个骨架的每一环都不是凭空而来。BN解决了深层网络训练难ReLU解决了Sigmoid梯度消失Strided Conv解决了池化信息损失——它们共同构成了现代CNN的“工业标准”。4. 卷积运算手把手拆解从二维到三维从单核到多核4.1 二维卷积纸上谈兵不如动手算取一张极简的6×6灰度图数值代表像素亮度[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]再取一个3×3卷积核检测垂直边缘[-1 0 1] [-1 0 1] [-1 0 1]Step 1确定输出尺寸公式Output_H floor((H 2*P - K) / S) 1其中H6输入高P0无填充K3核高S1步幅→Output_H floor((60-3)/1)1 4同理Output_W4。所以输出是4×4矩阵。Step 2手算第一个输出值位置[0,0]卷积核左上角对齐输入图左上角3×3区域输入区域 [1 2 3] 卷积核 [-1 0 1] [7 8 9] [-1 0 1] [13 14 15] [-1 0 1]计算(1×-1)(2×0)(3×1)(7×-1)(8×0)(9×1)(13×-1)(14×0)(15×1)-1 0 3 -7 0 9 -13 0 15 7Step 3滑动计算向右滑1步计算[0,1]位置再滑得[0,2]、[0,3]然后下移一行……最终得到4×4输出矩阵。你会发现输出中数值大的位置正是原图中垂直亮度突变如从1→7→13的列的地方——这把“尺子”真的在检测垂直边缘提示手算一遍的价值远超看十遍公式。你会真切感受到卷积不是抽象数学而是像素间的加权投票。每个输出值都是局部区域对某种模式的“置信度打分”。4.2 填充Padding为什么需要“补边”继续用6×6图但这次加padding1四周各补一圈0[0 0 0 0 0 0 0 0] [0 1 2 3 4 5 6 0] [0 7 8 9 10 11 12 0] [0 13 14 15 16 17 18 0] [0 19 20 21 22 23 24 0] [0 25 26 27 28 29 30 0] [0 31 32 33 34 35 36 0] [0 0 0 0 0 0 0 0]新尺寸8×8。再用3×3核、stride1卷积Output_H floor((80-3)/1)1 6输出变回6×6这就是same padding的核心目的保持空间尺寸不变让网络可以堆叠多层而不至于特征图迅速萎缩。注意padding不是“作弊”而是对边界信息的合理处理。不补零边界像素只参与一次卷积因为核无法完全覆盖导致边界特征弱于中心。补零虽引入人工值但实践中效果远好于不补。更高级的padding如“reflect”镜像反射或“replicate”复制边缘在特定任务如图像修复中效果更好。4.3 步幅Stride控制“滑动速度”的杠杆还是6×6图padding0但stride2Output_H floor((60-3)/2)1 floor(3/2)1 11 2输出2×2。这意味着卷积核每次向右/下跳2格大幅降低计算量同时实现下采样。实操心得在移动端部署模型时我常用stride2的卷积替代池化层。因为卷积能学习下采样模式如保留重要纹理而池化是固定规则取最大值前者更鲁棒。但要注意stride过大如3会导致信息丢失严重尤其对小目标检测不利。4.4 三维卷积RGB图上的真实战场真实图像不是6×6而是3×224×224C×H×W。卷积核也不再是3×3而是3×3×3——3个通道R,G,B各有一个3×3平面共27个参数。运算时输入图的3个通道分别与卷积核的3个通道平面做二维卷积得到3个中间结果再将这3个结果对应位置相加得到1个标量输出。所以一个3×3×3卷积核作用于3×224×224输入输出是1×222×222假设no padding, stride1。4.5 多维卷积核从1个“尺子”到32把“尺子”上例中我们只用1个卷积核输出1个特征图。但实际中我们会同时用32个不同的3×3×3卷积核每个核27个参数共32×27864参数每个核独立扫描输入生成自己的特征图。最终32个特征图堆叠起来形成32×222×222的输出张量——这就是“输出通道数C_out”的含义。关键理解通道Channel不是颜色而是特征维度。第1个通道可能响应“红色垂直边缘”第2个响应“蓝色水平线条”第32个响应“绿色圆形斑点”。网络通过训练自动学会分配这些“探测器”的职责。4.6 批处理Batch让GPU吃饱的工程智慧单张图计算太慢。我们一次喂给网络32张图batch_size32输入张量变为32×3×224×224。卷积运算时32张图是并行处理的同一个卷积核在32张图上同时滑动计算。输出张量变为32×32×222×222。这不仅是提速更是训练稳定的关键。BatchNorm依赖批次统计量均值、方差没有batchBN就失效。Adam优化器的自适应学习率也基于batch梯度估计。batch_size1逐样本训练在CNN中几乎不可行——它会让BN崩溃梯度噪声巨大收敛极慢。注意batch_size不是越大越好。显存有限制32×32×222×222×4字节float32≈ 200MB这只是中间特征图还没算参数和梯度。我常用经验法则显存GB÷ 2 ≈ 最大batch_size以ResNet-50为基准。5. 实操全流程用PyTorch从零构建一个可运行的CNN模块5.1 环境与数据准备5分钟搭好脚手架# 创建虚拟环境推荐 conda create -n cnn-tutorial python3.9 conda activate cnn-tutorial pip install torch torchvision matplotlib numpy数据用torchvision内置的MNIST手写数字它已预处理为28×28灰度图import torch import torch.nn as nn import torch.optim as optim from torchvision import datasets, transforms from torch.utils.data import DataLoader # 数据预处理转Tensor 归一化0-1缩放到-1到1 transform transforms.Compose([ transforms.ToTensor(), # HWC→CHW, uint8→float32, [0,255]→[0,1] transforms.Normalize((0.1307,), (0.3081,)) # MNIST均值/标准差 ]) train_dataset datasets.MNIST(./data, trainTrue, downloadTrue, transformtransform) test_dataset datasets.MNIST(./data, trainFalse, transformtransform) train_loader DataLoader(train_dataset, batch_size64, shuffleTrue) test_loader DataLoader(test_dataset, batch_size1000, shuffleFalse)提示Normalize参数不是随便写的。0.1307是MNIST所有训练图的像素均值0.3081是标准差。归一化让输入分布更集中加速收敛。不归一化也能训但学习率要调小10倍且容易震荡。5.2 构建CNN模型逐层解析代码含义class SimpleCNN(nn.Module): def __init__(self, num_classes10): super().__init__() # 第一层卷积32个3x3核输入1通道灰度输出32通道 self.conv1 nn.Conv2d(in_channels1, out_channels32, kernel_size3, stride1, padding1) # 批归一化对32个通道分别归一化 self.bn1 nn.BatchNorm2d(32) # 第二层卷积64个3x3核输入32通道输出64通道 self.conv2 nn.Conv2d(32, 64, 3, 1, 1) self.bn2 nn.BatchNorm2d(64) # 最大池化2x2窗口步幅2降维 self.pool nn.MaxPool2d(2, 2) # 全连接层输入是池化后特征图展平的维度 # 经过conv1→bn1→conv2→bn2→pool尺寸28→28→28→28→14 # 所以输入维度 64 * 14 * 14 12544 self.fc1 nn.Linear(64 * 14 * 14, 128) self.fc2 nn.Linear(128, num_classes) def forward(self, x): # x shape: [64, 1, 28, 28] (batch, channel, height, width) x self.pool(torch.relu(self.bn1(self.conv1(x)))) # [64, 32, 14, 14] x self.pool(torch.relu(self.bn2(self.conv2(x)))) # [64, 64, 7, 7] x x.view(x.size(0), -1) # 展平: [64, 64*7*7] [64, 3136] x torch.relu(self.fc1(x)) # [64, 128] x self.fc2(x) # [64, 10] return x model SimpleCNN() print(model)代码逐行解读nn.Conv2d(1, 32, 3, 1, 1)in_channels1灰度图out_channels3232个卷积核kernel_size33×3核stride1步幅1padding1same padding保尺寸。nn.BatchNorm2d(32)对32个通道分别做BN。注意BN层参数gamma, beta是按通道学习的不是全局。x.view(x.size(0), -1)x.size(0)是batch_size64-1表示自动计算剩余维度64×7×73136。这是展平操作为接全连接层做准备。5.3 训练循环不只是loss下降更要监控特征质量criterion nn.CrossEntropyLoss() # 分类任务标准损失 optimizer optim.Adam(model.parameters(), lr0.001) # Adam比SGD更稳 def train_one_epoch(model, train_loader, criterion, optimizer, device): model.train() running_loss 0.0 correct 0 total 0 for batch_idx, (data, target) in enumerate(train_loader): data, target data.to(device), target.to(device) optimizer.zero_grad() # 清空上一轮梯度 output model(data) # 前向传播 loss criterion(output, target) # 计算损失 loss.backward() # 反向传播计算梯度 optimizer.step() # 更新权重 running_loss loss.item() _, predicted output.max(1) # 取概率最大类别 total target.size(0) correct predicted.eq(target).sum().item() # 每100个batch打印一次 if batch_idx % 100 0: print(fBatch {batch_idx}, Loss: {loss.item():.4f}, Acc: {100.*correct/total:.2f}%) return running_loss / len(train_loader), 100.*correct/total # 训练主循环 device torch.device(cuda if torch.cuda.is_available() else cpu) model.to(device) for epoch in range(5): print(f\nEpoch {epoch1}/5) train_loss, train_acc train_one_epoch(model, train_loader, criterion, optimizer, device) print(fTrain Loss: {train_loss:.4f}, Train Acc: {train_acc:.2f}%)实操心得训练时务必监控训练准确率而非只看loss。我见过太多案例loss在降但准确率卡在10%随机猜说明模型根本没学可能是学习率太大导致梯度爆炸或数据加载出错标签全为0。另外optimizer.zero_grad()必须在每个batch开头调用否则梯度会累积导致爆炸。5.4 可视化卷积核看看你的网络学到了什么训练完我们可以提取第一层卷积核可视化它们在学什么import matplotlib.pyplot as plt import numpy as np # 获取第一层卷积核权重 (32, 1, 3, 3) kernels model.conv1.weight.data.cpu().numpy() # 绘制前16个核 fig, axes plt.subplots(4, 4, figsize(8, 8)) for i in range(16): ax axes[i//4, i%4] # kernels[i] shape: (1, 3, 3) - squeeze to (3, 3) kernel_img kernels[i, 0] ax.imshow(kernel_img, cmapgray, vminkernel_img.min(), vmaxkernel_img.max()) ax.set_title(fKernel {i1}) ax.axis(off) plt.tight_layout() plt.show()你会看到大部分核呈现明显的边缘检测模式类似我们手算的[-1,0,1]变体有些是水平有些是垂直有些是45度斜线。这证实了CNN确实在学习图像的底层结构。注意可视化要在训练充分后如5个epoch后进行。刚开始的核是随机初始化的看不出模式。另外不要期望看到“猫耳朵”这种高级模式——那是深层卷积核的任务第一层只负责基础纹理。6. 常见问题排查与避坑指南那些文档里不会写的细节6.1 问题速查表症状、原因、解决方案症状可能原因解决方案训练loss不下降准确率≈10%随机学习率过大导致梯度爆炸数据标签加载错误全为同一类模型未正确移到GPU降低学习率10倍用print(target[:5])检查标签确认model.to(device)和data.to(device)训练loss下降但验证准确率不上升过拟合验证集未归一化与训练集不一致batch_size太小导致BN统计不准加Dropout或L2正则确保验证集用相同transform增大batch_size≥32GPU显存不足CUDA out of memorybatch_size过大模型层数过多中间特征图太大如大图大核减小batch_size用更小的输入尺寸如224→112启用梯度检查点gradient checkpointing模型收敛极慢loss震荡大缺少BatchNorm学习率太小数据未归一化在每个卷积层后加BN增大学习率检查transform是否包含Normalize输出尺寸计算错误如期望28×28却得26×26padding设置错误stride理解偏差忽略了卷积核本身的尺寸用公式floor((H2P-K)/S)1手动验算记住padding0时输出尺寸必然减小6.2 那些“踩过坑”才懂的经验坑1认为“padding1”就是四周补一圈却忽略不同框架默认行为PyTorch的Conv2d(padding1)是对称填充上下左右各1但某些旧框架或自定义实现可能只补一侧。最稳妥的方法显式计算输出尺寸用torch.nn.functional.pad()手动补零验证。坑2在测试时忘记model.eval()BN和Dropout在训练和测试时行为不同。训练时BN用batch统计测试时用running_mean/varDropout训练时随机置零测试时全连接。忘记model.eval()会导致测试结果极不稳定。标准流程model.eval() # 切换到评估模式 with torch.no_grad(): # 关闭梯度计算省显存 for data, target in test_loader: output model(data) # ... 计算指标坑3用view()展平时维度算错常见错误x.view(-1, 64*7*7)。这是错的-1会把整个batch压成一维得到[64*7*7, 64*7*7]的荒谬形状。正确是x.view(x.size(0), -1)x.size(0)明确指定batch维度。坑4可视化卷积核时颜色失真直接plt.imshow(kernel)会因自动缩放导致细节丢失。必须用vmin/vmax固定范围或用plt.imshow(kernel, cmapRdBu, center0)突出正负权重。6.3 西电/山大期末高频考点实战解析结合你提到的“西电机器学习期末”“山东大学机器学习期末”我整理了3个必考计算题考点1输出尺寸计算必考题输入3×224×224卷积层64个3×3核stride2padding0。求输出尺寸。解H_out floor((2240-3)/2)1 floor(221/2)1 1101 111同理W_out111C_out64→64×111×111考点2参数量计算必考题同上卷积层求该层参数量不含bias。解3×3×3×64 17283输入通道×3×3核×64输出通道考点3感受野计算中频题LeNet-5中Conv15×5→ Pool12×2, s2→ Conv25×5求Conv
返回列表