第一周学卷积,最容易出现的状态是:课听完了觉得懂了,做题时一脸懵;对着答案看懂了,自己写代码又处处报错。这个现象太正常了,卷积涉及的计算逻辑和你以前的线性代数、神经网络知识不是一个思考维度,它需要你把“滑动窗口”“局部连接”“参数共享”这些概念同时装进脑子里,再落到代码里。这篇内容就是针对计算机视觉第一周卷积基础课的扫尾,我会把课后习题的解题思路掰开讲透,再把代码实践的步骤从头到尾过一遍,目的只有一个:让你在第二周开始前,真正把卷积这块地基夯实。
1. 第一周的知识边界:卷积到底解决什么问题
1.1 从全连接层的“浪费”说起
学卷积之前,得先搞清楚一个问题:图像为什么不能用普通全连接网络直接处理?一张 32x32 的彩色图片,如果拉成一个长向量,就是 32x32x3 = 3072 个数值。假设第一层设置 1024 个神经元,光这一层的参数量就是 3072x1024,约 300 万个参数。而这只是一层,还没算后续网络。如果是 224x224 的大图,全连接第一层的参数量会膨胀到没法看,训练不充分、数据量不够的话,直接过拟合。
更关键的问题是:全连接层处理图像时,把每个像素当作完全独立的特征,完全丢掉了像素之间的空间关系。但是在图像里,相邻像素之间是有强关联的——一只猫的耳朵、眼睛、胡须,都是由一片连续像素组成的局部特征。普通全连接网络“看不到”这种局部性,它只会像查字典一样机械地把所有像素值硬塞进神经元。卷积神经网络就是针对这两个痛点设计的:用局部连接减少参数,用参数共享进一步压缩模型量级,同时天然保留空间结构信息。
1.2 卷积的数学直觉:加权求和、滑动窗口
卷积这个名字听起来吓人,但如果你跳过教科书上的积分公式,从图像处理的角度看,它本质就是一句话:用一个小的固定矩阵(卷积核),在整个图像上按固定步长滑动,每到一个位置,把核覆盖下的像素值和核上的数字一一相乘,然后全部加起来,得到输出特征图上的一个点。
这个过程和你在图像处理软件里看到的“滤镜”“锐化”“模糊”是一回事,只是卷积神经网络里的核不是某个设计师手工设计的,而是通过反向传播自动学出来的。第一周课程往往会让你们从手工设计的 Sobel 算子、平滑核开始,目的不是让你们停留在传统图像处理,而是建立一个直观认识:卷积核的确在提取某种特征——边缘、纹理、颜色过渡。理解了这一步,再看卷积神经网络里的层叠结构,就明白它其实是在“从简单特征组合成复杂特征”。
这里有个容易混淆的点:图像处理里的二维卷积和神经网络里的卷积,虽然计算核心一样,但习惯略有不同。深度学习框架中的conv2d实际上做的是互相关(cross-correlation),也就是核直接滑过去乘加,不做翻转。这个差异不影响学习和使用,但你在看数学推导时会发现教科书里卷积公式有一步翻转,看到的时候别懵,知道深度学习实现里省掉了这一步就行。
1.3 第一周必须拿下的概念清单
概念这东西,上课时觉得都懂,一到做题就模糊。我建议你第一周结束前,用一张纸默写一遍下面这张表:
| 概念 | 一句话本质 | 常见误解 |
|---|---|---|
| 卷积核 | 一个用于提取局部特征的小矩阵 | 以为核越大越好,其实大核计算量大且未必更优 |
| feature map | 卷积输出的一张特征图,代表某种特征激活的空间分布 | 误把它当成最终分类结果 |
| padding | 在输入四周补一圈0,控制输出尺寸、保留边缘信息 | 忘了 padding 不是唯一的,补多少需要算 |
| stride | 核每次滑动的步长,能下采样、省计算 | 以为 stride 只影响尺寸,其实还影响感受野覆盖密度 |
| 通道 channel | 每个卷积核处理一个通道,多核叠出多个通道 | 混淆输入通道数和卷积核个数 |
| 池化 pool | 在局部区域做最大值/平均值提取,降低分辨率 | 以为池化是卷积的替代,其实两者作用不同 |
| 感受野 receptive field | 输出特征图上一个点对应回输入图像的范围 | 以为感受野只和卷积层数有关,没考虑 stride 和空洞率 |
这几项是后续所有卷积网络结构的基本零件,后面的代码实践和习题都围绕它们展开,必须做到随口就能说清。
2. 课后习题精讲:从公式记忆到真正理解
2.1 输出尺寸计算题:一个公式吃掉整类题
课后习题里最常见的就是给你输入尺寸、卷积核大小、padding、stride,问输出特征图多大。公式长这样:
output_size = floor((H + 2 * padding - kernel_size) / stride) + 1
看起来很机械,但有几个隐藏坑。第一个坑:这个公式的 H 是每个维度分别计算的,如果输入是 32x32,你算出 H 方向的尺寸,W 方向是同一个公式,因为一般卷积核是正方形、padding/stride 也一致,所以两个维度数值相同。但有些题故意把核设成 3x5,此时两个维度必须分开算。
第二个坑:不是所有尺寸都能被 stride 整除。比如输入 7x7,kernel_size=3,stride=2,padding=1:output = floor((7 + 2 - 3) / 2) + 1 = floor(6/2) + 1 = 4。这个整除情况比较友好,但换个输入 8x8,kernel_size=3,stride=2,padding=0:output = floor((8 - 3)/2) + 1 = floor(5/2) + 1 = 2 + 1 = 3。看到没,5/2 向下取整成 2,最后输出是 3。这个 floor 很多人第一遍都会忽略,导致算出来的结果和 PyTorch 实际跑出来的对不上。
推荐一个验证方法:不要空想,到 PyTorch 里直接跑一下。用torch.nn.Conv2d定义层,给一个torch.randn(1, 3, 8, 8)的输入,打印out.size(),立刻知道答案。我上课后辅导时,最常说的话就是“拿不准就打印一下 shape”,这不是偷懒,是工程习惯,模型层与层之间能不能对上,靠的就是这种即时验证。
2.2 感受野计算题:两层 3x3 为什么等价于一层 5x5
感受野的题,是第二高频考点。题目往往这么出:一个输入 32x32 的图像,经过两层 3x3 卷积,不 padding,最后特征图上的一个点对应原图多大区域?
这里不能只记公式,要理解递归关系。第一层卷积核是 3x3,输出特征图上的 1 个点看到了输入图上的 3x3 区域,所以感受野是 3。第二层卷积核还是 3x3,但它的输入是上一层特征图,上一层在特征图上的一个点已经包含了原图的 3x3 信息,第二层的 3x3 卷积在滑动时,实际上是让这些 3x3 信息再组合一次,所以感受野变成:3 + (3-1) = 5。推理过程是每经过一层带 k 大小的卷积,感受野扩大 (k-1) 乘以当前层的步长累积。
两层 3x3 能覆盖 5x5 区域,但参数量只有 2 x (3x3) = 18,而直接用一层 5x5 卷积需要 25 个参数,还少算通道数。别小看这个差别,现在的经典网络 VGG 就是靠堆叠 3x3 小卷积核,用更少的参数获得更大的感受野,同时因为中间多了层非线性激活,网络表达能力还更强。这道题背后的设计哲学,比公式本身重要得多。
做题时还有一个易错点:如果中间层的 stride 不是 1,感受野的扩张不是单纯的线性相加,要乘上步长的累积因子。我见过不少人把 stride=2 的层也算成感受野只加 2,结果差了十万八千里。第一周习题一般不会搞这么复杂,但你要有这个意识。
2.3 参数量计算题:小心偏置和多通道
参数量计算是很多人的送分题,也是送命题。题目常这样:输入 3 通道,输出 64 通道,卷积核大小 3x3,求这一层有多少参数。
很多人上来就写 3 x 3 = 9,错得离谱。正确的拆解:一个输出通道对应一个 3x3x3 的卷积核(因为输入有 3 个通道,每个通道都要一个 3x3 的核),所以一个输出通道的核参数量是 3x3x3 = 27,加上偏置 1,共 28。输出 64 个通道,总参数量 = 64 x (3x3x3 + 1) = 64 x 28 = 1792。
这个例子非常经典,因为它反映了卷积的通道对应关系:输入通道数决定每个输出卷积核的“厚度”,输出通道数决定要学几组这样的核。把这个逻辑想清楚,后面看 MobileNet 的深度可分离卷积、ResNet 的 1x1 卷积、Transformer 中降维升维操作,都不会再迷茫,因为这些操作本质上都离不开通道维度的调整。
2.4 问答题:padding 和 stride 的作用分别是什么
还有一类题,不算难,但答不到点上。问“为什么需要 padding”时,不要只写“防止尺寸变小”。要拆三点回答:
- 尺寸补偿:不 padding,每经过一次卷积,特征图尺寸就缩小,层数多了图像就没了;padding 可以在不改变输出分辨率的情况下加深网络。
- 边缘信息利用:核滑动到边缘附近的次数比中心少,导致边缘信息天然被“冷落”,padding 让边缘像素也有机会被核覆盖到,缓解信息衰减。
- 控制特征图尺寸:当你希望某一层输出保持某个固定分辨率,可以通过调整 padding 实现。
问“stride 的作用”时,同样要答两个层次:一是下采样,像步长为 2 时,输出尺寸近似减半,可以把特征图分辨率降下来,减少后续计算量;二是覆盖密度,stride 越大,核滑动的重叠区域越少,提取的特征越粗糙,这既是好事(省计算)也是坏事(丢细节),所以实际网络里常在前几层用小 stride,深几层再用大 stride。
2.5 判断题:池化层到底在干嘛
第一周习题里常混入池化层的问题,比如给一个 4x4 输入,2x2 最大池化,stride=2,问输出是什么。答案是输出 2x2,每个格子取原图对应 2x2 区域的最大值。但很多人只是机械算,不理解为什么需要池化。
这里我想多说一句,现在很多新网络(比如 ResNet 之后的不少结构)开始用 stride=2 的卷积替代池化层,导致一些初学者觉得池化过时了。其实池化有它不可替代的作用:最大池化提供了一种天然的不变性,当小物体平移一两个像素时,池化后的最大值位置虽然变了,但最大值本身还在,这种“对微小位移不敏感”的特性对分类任务非常有用。平均池化则适合保留背景信息。做选择题时,看到“增强平移不变性”“降低计算量”,往池化上靠基本没错;看到“增加非线性能力”,那是激活函数的事,别混。
3. 代码实践:把卷积“拆开”看一遍
3.1 环境准备与跑通第一个卷积
第一周的代码实践,我强烈建议直接用 PyTorch,不要先摆弄 TensorFlow 或者自己造轮子。原因很现实:PyTorch 的nn.Conv2d接口简单直接,可视化方便,资料多,遇到报错去搜社区基本都有答案。
环境建议:Python 3.8 以上,安装 torch 和 torchvision,CPU 版就够跑,GPU 无所谓。第一次验证环境,生成一个随机张量过一遍卷积层:
import torch import torch.nn as nn # 输入:batch_size=1, 通道数=3, 尺寸=32x32 x = torch.randn(1, 3, 32, 32) # 卷积层:输入通道3,输出通道8,卷积核3x3,padding=1 conv = nn.Conv2d(in_channels=3, out_channels=8, kernel_size=3, padding=1) out = conv(x) print(out.shape) # 输出:torch.Size([1, 8, 32, 32])这段代码跑通后,先别急着往下写。把它当成一个“活的公式计算器”,改改 kernel_size、stride、padding,观察输出 shape 的变化,和第一节的公式对比,你会比看十遍课件都记得牢。
3.2 用真实图像做边缘检测:手工设计核的实践
接下来用一个经典练习建立直觉:把一张彩色图转成灰度,用 Sobel 算子做边缘检测。Sobel 算子的水平方向核是这样的:
-1 0 1 -2 0 2 -1 0 1垂直方向则是转置。边缘在图像上表现为像素值的剧烈跳变,这个核的加权求和,能把这个跳变放大成明显的响应值。代码如下:
import torch import torch.nn as nn import numpy as np from PIL import Image import matplotlib.pyplot as plt # 读取图像并转为灰度,像素值归一化到 [0,1] img = Image.open("sample.jpg").convert("L") img_np = np.array(img, dtype=np.float32) / 255.0 x = torch.from_numpy(img_np).unsqueeze(0).unsqueeze(0) # shape: (1,1,H,W) # 定义水平 Sobel 卷积核 sobel_kernel = torch.tensor([[[[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]]]], dtype=torch.float32) # 用 nn.Conv2d 包装核,固定权重,不加偏置 conv = nn.Conv2d(in_channels=1, out_channels=1, kernel_size=3, padding=1, bias=False) conv.weight.data = sobel_kernel with torch.no_grad(): edge = conv(x) # 可视化 plt.figure(figsize=(10, 4)) plt.subplot(1, 2, 1) plt.imshow(img_np, cmap="gray") plt.title("Original") plt.axis("off") plt.subplot(1, 2, 2) plt.imshow(edge.squeeze().numpy(), cmap="gray") plt.title("Sobel Edge") plt.axis("off") plt.show()这段代码看起来简单,但有三个细节值得你留意。第一,nn.Conv2d默认会随机初始化权重,必须手动conv.weight.data = sobel_kernel覆盖,否则跑出来是一堆噪声。第二,bias=False是必须的,因为 Sobel 核本身不带偏置,加上偏置会破坏检测效果。第三,图像的数值范围一定要归一化到 0~1,如果你从 PIL 读到的是 0~255 的 uint8,先转 float32 再除以 255,否则结果会异常。
用真实图像跑完这个实验,我对“卷积核就是特征提取器”这句话才有体感:同一张图,换不同的核,输出完全变个样。第一周实践不追求训练深度学习模型,这种“手工核可视化”反而是最好的启蒙。
3.3 自己写一个最朴素的卷积:理解内部循环
用nn.Conv2d跑通之后,我建议你再手写一个最朴素的卷积实现,不用考虑效率和反向传播,只是把前向计算用三层循环写出来。这个练习的价值在于:它会强制你把 padding、stride、多通道、多输出核这些概念全部过一遍,任何一处理解不到位,代码都跑不对。
def conv2d_simple(inputs, kernels, bias, stride=1, padding=0): """ inputs: numpy 数组,shape=(C_in, H, W) kernels: numpy 数组,shape=(C_out, C_in, kH, kW) """ C_in, H, W = inputs.shape C_out, _, kH, kW = kernels.shape # 先 padding padded = np.zeros((C_in, H + 2 * padding, W + 2 * padding), dtype=np.float32) padded[:, padding:padding + H, padding:padding + W] = inputs out_h = (padded.shape[1] - kH) // stride + 1 out_w = (padded.shape[2] - kW) // stride + 1 output = np.zeros((C_out, out_h, out_w), dtype=np.float32) for c_out in range(C_out): for i in range(out_h): for j in range(out_w): # 找出对应区域 region = padded[:, i * stride:i * stride + kH, j * stride:j * stride + kW] # 每个输出通道的核,对输入所有通道加权求和 value = np.sum(region * kernels[c_out]) + bias[c_out] output[c_out, i, j] = value return output这段代码的循环顺序是:先遍历输出通道,再遍历输出特征图的高和宽,每个输出位置都做一次全通道的加权求和。你把它和nn.Conv2d的结果对比一下,就能发现自己有没有踩坑,比如偏置到底该加在每个输出通道还是每个卷积核上,padding 到底pad在哪个维度。这个“和自己写的代码对比”的过程,就是课后实践的核心价值。
3.4 进阶一点:训练一个最小的卷积分类器
如果前面几步都顺利,可以再加一个小挑战:用 PyTorch 训练一个单卷积层加全连接层的分类器,跑 MNIST 手写数字数据集。MNIST 图是 28x28 单通道,一个nn.Conv2d(1, 16, kernel_size=3, padding=1),接nn.MaxPool2d(2),再接nn.Flatten()和nn.Linear(16 * 14 * 14, 10),用交叉熵损失,Adam 优化器,训练 5 个 epoch。
这个练习会让你接触到训练的完整链路:数据加载、前向传播、损失计算、反向传播、参数更新。第一周不要求你把训练代码写得多漂亮,能跑通、能看到 loss 下降就行。但你做完之后,会把卷积层的可学习参数这个概念彻底理解——前面手动覆盖的 Sobel 核是固定的,这里卷积核会通过梯度更新不断变化,模型自己学着找出更有效的特征组合。
4. 作业之外的进阶方向:那些卷积热词都是什么
4.1 深度可分离卷积:减少计算量的利器
第一周结束后,你在网上搜“卷积神经网络”会看到一堆热词:MobileNet、深度可分离卷积、空洞卷积、一维卷积、TCN。这些名字听着高级,但根子都在基础卷积上,我先挑两个最容易出现在后续课程里的说。
标准卷积的计算量是 C_in x C_out x k x k x H_out x W_out。深度可分离卷积把它拆成两步:第一步叫逐通道卷积,每个输入通道单独用一个 k x k 的核做卷积,这时输出通道数等于输入通道数;第二步叫逐点卷积,用 1x1 卷积把逐通道卷积的输出通道数变换到想要的 C_out。计算量从 C_in x C_out x k x k 变成 C_in x k x k + C_in x C_out,省下多少?当 k=3 且 C_out 很大时,大约缩小到原来的九分之一。MobileNet 系列能在手机端跑,靠的就是这个设计。
理解它的关键在于把你之前算卷积参数量的思路用上:通道维度的操作和空间维度的操作被解耦了。这个思路后来也被 Transformer 中的 depthwise convolution、以及各种轻量 attention 结构吸收了,属于“学一次,受用终生”的知识点。
4.2 空洞卷积:用更少参数获得更大感受野
空洞卷积(Dilated Convolution)是另一个高频热词。它的核心是在卷积核内部插入“空洞”,让核覆盖的范围变大,但实际参与计算的像素数不变。比如一个 3x3 核,dilation=2 时,它覆盖的范围是 5x5,但中间隔一个像素取一个,真正乘加的仍然是 9 个点。
这个设计大量用在语义分割任务里,因为分割既要高分辨率的空间细节,又需要大感受野来理解语义,常规做法是不断卷积+池化缩小图,但这样会丢失细节。空洞卷积直接解决了“大感受野和高分辨率不可兼得”的矛盾。计算感受野的公式也升级了:有效核大小 = dilation x (kernel - 1) + 1。回到前面那道两层 3x3 的题,如果第二层改成 dilation=2,感受野就不是 5,而是 3 + (5-1) = 7,参数量却没增加。这个扩展在数学上很优雅,值得在基础周就建立印象。
4.3 一维卷积与时间序列预测:TCN 背后的逻辑
搜索热词里频繁出现 TCN 时间卷积网络和股票预测,这其实是卷积的一个延伸场景。初学者容易把卷积默认成二维图像专用,其实一维卷积在处理时间序列时也很有用,比如股票价格序列、传感器信号、音频。
一维卷积核相当于一个滑动窗口,沿时间轴滑动,对窗口内的数值做加权求和。TCN 的核心结构是因果卷积,意思是输出 t 时刻的值时,只用 t 时刻及之前的输入,不往后看,这符合时间序列“不能预知未来”的约束。再加上膨胀卷积扩大时序感受野,让模型能捕捉更长距离的依赖关系。和 LSTM 相比,TCN 可以并行处理整个序列,训练速度快,这是它的一大优势。
不过我想提醒你:第一周先把二维卷积的空间直觉建立好,再去看一维卷积和 TCN 会顺手很多,因为它们本质上都是在做“局部加权求和”,只是滑动方向和数据结构不一样。股票预测这种话题很有吸引力,但别在基础没打牢时就急着上 Transformer 和 TCN 实战,那会把精力浪费在调参上面,而不是理解原理上。
4.4 深度可分离、空洞、一维:一张表看懂关系
| 卷积类型 | 改动点 | 解决的问题 | 典型应用 |
|---|---|---|---|
| 标准 2D 卷积 | 无 | 基础特征提取 | 图像分类、检测 |
| 深度可分离卷积 | 拆成逐通道+逐点两步 | 大幅降低计算量 | MobileNet、边缘设备 |
| 空洞卷积 | 核内插入空洞 | 扩大感受野不增加参数 | 语义分割、目标检测 |
| 1D 卷积 | 核沿时间轴滑动 | 处理时间序列 | 语音、股票预测的 TCN |
| 转置卷积 | 上采样操作 | 把低分辨率特征图放大 | 生成模型、分割解码器 |
这张表可以贴在你笔记里,后续课程每遇到一种新卷积,就归类进这个框架:“它改了什么,为了解决什么,用在哪”,学习效率会高很多。
5. 我踩过的坑和给你的练习建议
5.1 四个每天都在重复的低级错误
代码实践多了,你会发现大部分报错根源就那几个,第一周尤其集中。
第一个:输入张量的 shape 不对。PyTorch 的卷积层要求输入是 (N, C, H, W) 四维,而很多人直接从 PIL 读到的是二维数组,忘记加 batch 和通道两个维度。这个错频到什么程度?几乎每个初学者第一天都会遇到,报错信息里写着 “Expected 4D input”,你第一反应应该是去检查.unsqueeze()加对了没。
第二个:归一化没做。图像像素是 0~255 的整数,直接作为输入时数值范围太大,梯度容易爆炸,特征响应也会异常。推荐在数据加载阶段就x / 255.0到 0~1 之间,或者用 PyTorch 的transforms.Normalize做标准化。
第三个:卷积层定义时,in_channels和上一层的输出通道对不上。这是个非常经典的维度不匹配错误,解决方法是每次写完一层,打印一下输出 shape,记录下通道数,下一层直接对照着写。
第四个:训练时忘了model.train()和model.eval()的状态切换。有些层(如 BatchNorm、Dropout)在不同状态下行为不同,第一周虽然不一定用到 BatchNorm,但从一开始养成切换状态的习惯,后面会少很多坑。
5.2 一个有效的练习路径:从“看”到“改”再到“造”
给零基础同学一条具体练习路径。第一天,把课程代码跑通,重点看nn.Conv2d各种参数变化对 shape 的影响。第二天,用真实图像跑手工核可视化,把 Sobel、平滑、锐化都试一遍。第三天,手写一个朴素卷积实现,和 PyTorch 结果对比,这一步能检验你有没有真正理解循环里的对应关系。第四天,把 MNIST 分类器跑通,哪怕只训练几个 epoch,目标是看到 loss 下降。第五天,尝试自己改网络,比如把卷积核从 3x3 改成 5x5,观察参数量和效果的变化。
这套路径的核心思想是“先跑通再理解”,先让代码动起来,然后逐个环节深化理解。别想着一口吃成胖子,第一周就把所有卷积变体都学会,这既不现实也没必要。
5.3 最后提醒:别急着冲高级模型
第一周结束的时候,你会看到社区里各种 TC N+Transformer 预测股票的项目,感到焦虑很正常,但请按捺住。那些项目看起来炫酷,本质上还是由你这一周学的卷积基础部件搭建起来的,如果基础不牢,你去复现时会在数据预处理、维度匹配、训练稳定性的泥潭里耗尽信心。
把课后习题做透,把代码实践里的每一步验证跑通,比预习第二周的内容更重要。我见过太多人第一周急着往前赶,结果过了三周还要回来补“什么是 padding”,学习这种事,慢就是快。卷积基础是计算机视觉的地基,你可以走得慢,但每一步都要踩实。