作为过来人,我先说句实在话:“计算机视觉 第一周:卷积基础知识”这个标题,几乎是所有人入门CV的第一道坎,也是第一座分水岭。很多同学在学到这里时,感觉PPT上全是矩阵、箭头和公式,代码一跑全是报错,课后习题更是不知道从哪里下手。这篇文章我就结合第一周的核心知识点,把卷积的数学原理、代码实践(含可直接复现的Python代码)以及课后习题的拆解思路一次性讲透,帮你把“看得懂”变成“写得出来、讲得清楚”。
如果你是刚打开计算机视觉大门的新手,或者正在为第一次作业发愁,这篇文章就是按你需要的顺序写的;哪怕你已经对卷积有了点模糊的认识,我也建议把代码部分亲手敲一遍,因为“你以为你会了”和“你真有本事让卷积在你自己的电脑上跑起来”之间,差的就是一个完整的上机实践。
1. 整体学习思路与第一周核心目标拆解
1.1 为什么第一周一定要死磕卷积
很多初学者会问:计算机视觉不是搞人脸识别、自动驾驶、目标检测的吗?怎么第一周就在讲卷积?我的回答很简单:卷积是当前几乎所有主流视觉模型的基本组成单元。不管后面学ResNet、YOLO还是Transformer系列,你都要和一个叫“卷积层”的东西打交道。第一周把卷积的“魂”抓住,后面你看到任何网络结构图,看到的都不是一堆方块,而是一连串有明确物理意义的特征提取过程。
1.2 第一周的三个核心目标
按照我带人入门的经验,第一周不需要你去看长篇论文,也不要急着调库训练模型。你需要死磕三件事:
- 理解卷积的数学本质和图像意义:从“加权求和”的角度理解卷积核如何扫描图像,知道常见的平滑、锐化、边缘检测卷积核为什么长那样。
- 掌握卷积层的关键基本概念:卷积核尺寸(Kernel Size)、步长(Stride)、填充(Padding)、通道数(Channels)、特征图(Feature Map)尺寸计算公式。这五个概念是你后面调试所有CNN模型的基础。
- 用Python从零手写卷积过程:不要一开始就用
torch.nn.Conv2d完事。我强烈建议先用NumPy手写一个针对单通道、单卷积核的二维卷积,再扩展到多通道、多卷积核,最后再用PyTorch实现并对比结果。
这周基础打牢了,后面学激活函数、池化、经典网络结构时,你会觉得像爬楼梯一样顺。我用带新手最直观的一个类比来帮你建立感性认识:卷积核就像你拿一个手电筒在图像上滑动照过,但照到的每个区域都会和一个“模板”(卷积核)做加权匹配,匹配后的结果就是新图像上的一个像素。后面你会发现,这其实就是“模板匹配”思想的延伸。
2. 卷积基础知识解析:从连续到离散,从公式到直觉
2.1 卷积的数学定义与图像领域简化
先看数学定义。一维连续卷积的公式是:
[ (f * g)(t) = \int_{-\infty}^{\infty} f(\tau) g(t - \tau) d\tau ]
刚接触这个公式时,你可能会觉得头晕:又是积分又是翻转的,跟图像有什么关系?其实在数字图像处理里,我们用的是二维离散卷积,公式长这样:
[ (f * g)(i, j) = \sum_{m} \sum_{n} f(m, n) \cdot g(i - m, j - n) ]
但在深度学习的实际应用中,我们一般用的是“互相关”,也就是卷积核不做翻转,直接在图像上滑动点乘求和。严格来说它们在数学上有差异,但在CNN里大家习惯上都叫“卷积”。这是很多教材没讲明白、但考试和面试都爱问的细节。你只要记住:深度学习框架里的“卷积层”实际做的是互相关运算,它没有真正翻转卷积核,因为卷积核是学习出来的,翻转与否都能通过训练学到等价的效果。
2.2 单通道二维卷积手算示例
为了把“加权求和”这件事彻底搞懂,我们来看一个具体的例子。假设有一张5x5的输入图像,卷积核是3x3:
输入图像:
[[ 1, 2, 3, 0, 1], [ 4, 5, 6, 1, 2], [ 7, 8, 9, 2, 3], [ 0, 1, 2, 3, 4], [ 5, 6, 7, 8, 9]]卷积核(比如一个垂直边缘检测器):
[[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]]假设步长stride=1,padding=0。那么这个卷积核首先覆盖输入图像左上角的3x3区域(即[[1, 2, 3], [4, 5, 6], [7, 8, 9]]),然后逐元素相乘再求和:
[ (1 \times -1) + (2 \times 0) + (3 \times 1) + (4 \times -2) + (5 \times 0) + (6 \times 2) + (7 \times -1) + (8 \times 0) + (9 \times 1) = -1 + 0 + 3 - 8 + 0 + 12 - 7 + 0 + 9 = 8 ]
所以输出特征图左上角的第一个像素值就是8。按这个逻辑,卷积核从左到右、从上到下以步长1滑动,每次计算一个输出像素。最终输出特征图尺寸是多少?用公式算:
[ H_{out} = \left\lfloor \frac{H_{in} + 2 \times padding - kernel_size}{stride} \right\rfloor + 1 ]
代入数值:( (5 + 0 - 3) / 1 + 1 = 3 )。所以输出是3x3的特征图。这个公式非常关键,后面所有CNN的结构计算都用得到,我建议你把它记在笔记的第一页。
2.3 卷积在图像上到底做了什么
从上面的手算例子可以看出:每滑动一次,卷积核就是在提取当前位置相邻像素间的某种特征关系。比如Sobel算子能检测边缘,是因为它中心附近权重有正有负,平坦区域的加权和接近0(因为正负抵消),而边缘区域因为有灰度的突变,结果值就会比较大(正或负)。后续网络中我们会在图像特征图上不断叠加卷积层,输入是原图,输出是“特征响应图”,网络越深,提取到的特征越抽象。第一周你不必完全明白深层特征的含义,但你必须建立这个直觉:卷积核是对局部区域的模式做匹配。这一点想通了,其他都好办。
3. 课后习题核心考点与常见题型拆解
3.1 必考1:特征图尺寸计算
不管是测验还是第一次大作业,几乎必考一题:给定输入尺寸、卷积核大小、padding、stride,求输出尺寸。
我直接给你一个极简的思考顺序:先算“卷积核在输入上能放下多少个位置”。
- 如果padding=0,stride=1,kernel=3,输入5x5,那就是(5-3+1=3)个位置,输出3x3。
- 如果padding=1,输入仍然是5x5,kernel=3,stride=1,那边缘各补一圈0,输入“变成”7x7,输出就是(7-3+1=5),尺寸不变。
- 如果stride=2,输入5x5,kernel=3,padding=0,那就是((5-3)/2+1=2),输出2x2。
习题里常见的陷阱是“padding的加法是两边同时加”。很多人记padding=1时直接在原尺寸上+1,其实等价于原尺寸+2。我建议你在草稿纸上画一画“带padding后的输入长什么样”,画一次就忘不了。还有一类题目,问你在某一层之后特征图的通道数是多少。规则很简单:通道数等于这一层卷积核的个数,跟输入通道数无关——每个卷积核负责生成一张独立的特征图。举个例子,如果输入是64x64x3(RGB图像),这一层有8个卷积核,那么输出就是64x64x8(假设尺寸不变)。这题是送分题,千万别错。
3.2 必考2:卷积层的参数量计算
第二个高频考点是:给定输入通道数、卷积核尺寸、输出通道数,求该卷积层的参数量(不算偏置)和计算量。
参数量公式很简单:
[ 参数量 = 输入通道数 \times 卷积核高度 \times 卷积核宽度 \times 输出通道数 ]
例如输入3通道,卷积核3x3,输出16通道,那么参数量就是(3 \times 3 \times 3 \times 16 = 432)。如果算偏置,就再加上输出通道数(因为每个输出通道共享一个偏置),变成448。
计算量稍微复杂一点,但也很机械:
[ 计算量 = 输出特征图高度 \times 输出特征图宽度 \times 输入通道数 \times 卷积核高度 \times 卷积核宽度 \times 输出通道数 ]
为什么这么算?因为输出特征图上的每一个像素,都需要做“输入通道数×卷积核高×卷积核宽”次乘加运算。这个也建议你动手推一次,把“输出的每一个像素是怎么来的”在纸上画出来,比背公式有效得多。考试时这一题通常是计算量最大的,但只要逻辑对了就不会错。
3.3 必考3:感受野与边界效应
第三类常见题是概念辨析题:什么是感受野?为什么需要padding?卷积层的输出尺寸相比输入变小了,信息是否会丢失?
这类题答的时候要有层次。感受野指的是输出特征图上一个像素点对应到输入图像上的区域大小。对于连续多个卷积层,感受野的计算要逐层反推,但第一周通常只会考单层感受野(即卷积核本身的大小)。padding的核心作用有两个:一是控制输出特征图尺寸,防止逐层缩小得太多;二是更好地保留边缘信息——如果不padding,边缘像素被卷积核覆盖到的次数远少于中心像素,那边缘信息天然就被弱化了。这就是“边界效应”。你答到“边缘像素参与计算次数少”这个点,老师就知道你真懂了。
3.4 原理解答题:卷积为什么能提取特征
最后一类常见问答题是:为什么通过卷积神经网络能够提取图像的局部特征?怎么理解“局部感知”和“参数共享”?
这里我给你一个直接能用的回答框架:
- 局部感知:卷积核每次只覆盖输入的一小片区域,因此每个输出像素只与输入的一个局部邻域相关,这符合图像中邻近像素关联紧密、远处像素关联弱的自然特性。
- 权重共享:同一个卷积核在整张图上滑动时,它的参数是固定的。也就是说,同一个特征检测器被应用到了所有位置——不管是一只猫出现在图片的左上角还是右下角,同一组卷积核都能识别到相似的局部模式。这大幅降低了参数量(相比全连接层),也使模型具备一定的平移不变性。
- 把多个不同的卷积核叠在一起,网络就能在每一层提取多种不同的局部特征(边缘、纹理、颜色变化等),层数加深后,高层特征就由低层特征组合而成,最终实现从像素到语义的映射。
以后再有“为什么CNN比全连接网络更适合图像”之类的题,你直接按这三点答,基本不会扣分。
4. 代码实践:从零实现并用PyTorch验证
4.1 环境准备与建议
第一周代码实践我用的是Python + NumPy + PyTorch。PyTorch是现在学术界和工业界用得最广的框架之一,遇到问题好搜、好学、好问。关于环境,你可以直接pip install torch torchvision numpy matplotlib。至于用不用GPU——第一周完全不需要,CPU跑这个实践绰绰有余。
4.2 用NumPy手写二维卷积(Scipy的signal.convolve2d其实也行,但建议自己写)
我建议你至少自己写一次循环实现的卷积,这样对“滑动窗口”的理解会彻底到位。下面这段代码接收一个二维输入和一个二维卷积核,返回特征图:
import numpy as np def conv2d_numpy(image, kernel, padding=0, stride=1): # image: 2D array # kernel: 2D array # 这里假设 kernel 是奇数尺寸,方便计算中心 if padding > 0: image_padded = np.pad(image, pad_width=padding, mode='constant', constant_values=0) else: image_padded = image H, W = image_padded.shape kh, kw = kernel.shape H_out = (H - kh) // stride + 1 W_out = (W - kw) // stride + 1 output = np.zeros((H_out, W_out)) for i in range(H_out): for j in range(W_out): region = image_padded[i*stride:i*stride+kh, j*stride:j*stride+kw] output[i, j] = np.sum(region * kernel) return output # 测试:用上一节手算的例子 image = np.array([ [1, 2, 3, 0, 1], [4, 5, 6, 1, 2], [7, 8, 9, 2, 3], [0, 1, 2, 3, 4], [5, 6, 7, 8, 9] ], dtype=np.float64) kernel = np.array([ [-1, 0, 1], [-2, 0, 2], [-1, 0, 1] ], dtype=np.float64) out = conv2d_numpy(image, kernel, padding=0, stride=1) print(out) print("输出尺寸:", out.shape)你运行一下,左上角第一个值就是8。这一步很重要:把程序跑出来的结果和你手算的结果作对比,如果一致,说明你对卷积实现的理解完全正确。如果还没画过图,我个人建议用matplotlib把原图和输出特征图画出来,尤其是边缘检测核的输出,你会发现特征图上亮的地方正好是原图里灰度突变的位置。这种直观的反馈比看十遍公式都管用。我在带人学的时候,发现很多新手不画图只看数值,学过就忘——所以这个环节务必亲自做一遍。
4.3 矩阵乘法视角:从嵌套循环到im2col
手写循环版优点是直观,缺点是太慢,而且不利于你理解GPU为什么适合深度学习。这里我推荐你了解一下im2col思想:把卷积操作转化为大矩阵乘法。简单说,就是把每个卷积核要覆盖的局部区域拉平成向量,拼成一个矩阵,再把卷积核也拉平成向量作为另一个矩阵,二者做矩阵乘法,就一次性算出所有输出值。这个技巧在很多底层优化库里都在用。你第一周不一定非要用它写代码,但知道这个思路后,再看torch.nn.Unfold、F.conv2d的用法,会有一种“原来如此”的感觉。
4.4 用PyTorch实现Conv2d并验证结果
手写循环验证之后,我们再来看PyTorch怎么一行搞定。
import torch import torch.nn as nn # 将numpy数组转为PyTorch张量,形状为 (batch, channels, height, width) image_tensor = torch.tensor(image, dtype=torch.float32).unsqueeze(0).unsqueeze(0) kernel_tensor = torch.tensor(kernel, dtype=torch.float32).unsqueeze(0).unsqueeze(0) # 使用 nn.Conv2d,手动设置权重,并关闭偏置 conv_layer = nn.Conv2d(in_channels=1, out_channels=1, kernel_size=3, stride=1, padding=0, bias=False) with torch.no_grad(): conv_layer.weight.copy_(kernel_tensor) output_tensor = conv_layer(image_tensor) print(output_tensor)结果应该和NumPy版完全一致。这里你可能会好奇nn.Conv2d的weight形状为什么是(out_channels, in_channels, kh, kw)。因为它要一次性处理多输入通道、多输出通道的情况。我建议你课后自己再写一个多通道版本,比如模拟一个RGB三通道输入、使用两个卷积核的情况,输入形状为(1, 3, H, W),看看输出的形状是不是(1, 2, H_out, W_out),并手动验证一下“参数量 = 3×3×3×2”这个公式。
4.5 特征图可视化
为了把“卷积提取特征”这个抽象概念落到具体感受上,我强烈建议你做一次可视化。用matplotlib画一个灰度图(你可以自己用skimage.data.camera()加载一张相机图,或者任何你喜欢的图片),然后用三种不同的经典卷积核分别处理它:水平Sobel检测水平边缘、垂直Sobel检测垂直边缘、高斯核做平滑。代码大概长这样:
import matplotlib.pyplot as plt from skimage import data from scipy.signal import convolve2d img = data.camera() sobel_x = np.array([[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]]) sobel_y = np.array([[-1, -2, -1], [0, 0, 0], [1, 2, 1]]) gaussian = (1/16) * np.array([[1, 2, 1], [2, 4, 2], [1, 2, 1]]) fig, axes = plt.subplots(2, 2, figsize=(8, 8)) axes[0, 0].imshow(img, cmap='gray'); axes[0, 0].set_title('Original') axes[0, 1].imshow(convolve2d(img, sobel_x, mode='same'), cmap='gray'); axes[0, 1].set_title('Sobel X') axes[1, 0].imshow(convolve2d(img, sobel_y, mode='same'), cmap='gray'); axes[1, 0].set_title('Sobel Y') axes[1, 1].imshow(convolve2d(img, gaussian, mode='same'), cmap='gray'); axes[1, 1].set_title('Gaussian') plt.tight_layout() plt.show()你会在画面上清晰地看到水平边缘和垂直边缘的差异。这是第一周最直观的成就感来源,也是你以后理解卷积层可视化的重要铺垫。很多同学做完这一步以后,再看卷积网络里的特征图,心里就不慌了。
5. 常见报错、排错技巧与避坑经验
5.1 维度不匹配是新手最大的坑
代码实践中最常见的错误就是Expected stride to be a single integer value or a list of 1 values to match the convolution dimensions, but got stride=...这类和张量形状有关的报错。我见过太多新手卡在这一步,其实多数是因为PyTorch的输入要求是4D张量(N, C, H, W),而你把二维图像直接传进去了。解决办法很简单:先image_tensor = torch.tensor(image).unsqueeze(0).unsqueeze(0),一步步把形状从(H, W)变成(1, 1, H, W)。每写一步torch.Size打印一下,确认没问题再做下一步,这是所有深度学习调参的基本功。
5.2 手写卷积结果和PyTorch结果不一致
如果手写循环的数值和PyTorch算的不一致,排查顺序如下:
- 检查是否写成了对卷积核翻转的版本。上面提过,深度学习里的“卷积”实际是互相关,如果你手动实现了翻转,可以和PyTorch误差较大(除非卷积核本身对称)。
- 检查数据精度。NumPy默认是
float64,PyTorch默认是float32,精度差异可能导致最后几位小数不同,用np.allclose判断而不是用==。 - 检查初始化。如果直接用
torch.nn.Conv2d而不复制权重,卷积核是随机初始化的,那和手写核当然对不上。你需要在torch.no_grad()下用.copy_()把核覆盖掉。
5.3 关于填充计算的几个常见误解
做课后习题时,我发现很多人容易把“padding=1”理解为“只在一侧加一行0”。实际上padding=1是在上下左右各加1行/列0,相当于把输入的H和W各增加2。举一个最容易搞混的场景:输入5x5,kernel=3,padding=1,stride=1,输出是多少?按公式:((5 + 2 - 3) / 1 + 1 = 5),输出5x5。如果你的答案不是5,说明填充理解有偏差,建议画个带0边的图重新算。另外,PyTorch中还有一个padding_mode='replicate'可以让你选择填充方式(复制边缘像素而非填0),如果以后做图像补全、分割任务时会很有用,第一周可以先不深究。
5.4 计算资源不充裕时的建议
第一周的代码量很小,不需要GPU。但如果你在本地安装PyTorch遇到网络慢或版本兼容等问题,一个建议是使用Google Colab或国产的Kaggle镜像环境——浏览器里就能跑,省去一堆环境折腾的时间。我自己第一次带学生做这个作业时,发现至少三分之一的时间花在了环境安装上。我的建议是:能用云环境跑通流程,就先用云环境;等代码完全跑通了,再回本地解决环境问题。这样写代码的进度不会卡在安装上。
6. 第一周进阶:从单层卷积到卷积神经网络的桥接
6.1 理解“卷积核是学出来的”
手写固定核(Sobel、高斯)是帮你建立直觉,但深度学习的核心在于:卷积核不是人设计的,而是通过反向传播从数据中学出来的。第一周的末尾,我建议你做一个小实验:用随机初始化卷积核来卷积一张图,观察输出特征图的样子;再训练一个极小的网络(输入图像,输出二分类)后,再看最终卷积核长什么样。你会发现,训练后的卷积核会呈现某种有序的边缘/纹理模式,这就是“学习”的意义。如果你时间充裕,还可以看看网上分享的“第一层卷积核可视化”图片,几乎都能看到类似边缘和颜色检测器的模式。这个认识的转变,是你从“明白卷积”走到“明白神经网络”的重要一步。
6.2 第一周习题的延伸思考题
在课后习题的最后,通常会有一些开放思考题,比如“如果卷积核尺寸为1x1,它到底在做什么?”这里我先给个提示,实际动手也是一种乐趣:对单张RGB图像来说,1x1卷积是在做“逐像素的跨通道线性组合”,也就是混合每个位置的三原色通道,类似一个全连接层作用在每个像素上。现在很多网络利用1x1卷积来调整通道数,实现“瓶颈”结构。如果你能把这个问题想明白,说明你对卷积的理解已经超过“扫描窗口”这个层次了。还有一道题也经常被拿出来讨论:为什么很多现代网络喜欢用2个连续的3x3卷积而不是1个5x5卷积?这不只是为了减少参数,还因为两个3x3卷积叠加后,感受野是5x5,但中间多了一层非线性变换,表达能力更强。这道题第一周可能做不出来,但先留个印象,以后学VGG时会豁然开朗。
6.3 给代码实践做的最后加分项
如果第一周作业搓完还有余力,我建议你试一次把“手写卷积”改成“批量处理多张图、多通道、多卷积核”的版本。也就是让你的函数支持输入形状为(N, C, H, W)的NumPy数组,并返回(N, F, H_out, W_out)的输出。完成这一步后,你会发现PyTorch的nn.Conv2d不过是一个把你的实现高度优化并支持反向传播的封装而已。到了后面学习nn.Sequential、nn.Module时,你对网络内部结构会非常有底气。第一周能写到这个程度,已经比同批次很多人强了。
7. 典型习题速查表与排错参考
我整理了一张速查表,你可以贴在笔记里。做题、调试代码时遇到不确定的,回来对一眼就好。
关键公式速查
| 项目 | 公式 | 示例(输入5x5,核3x3,stride=1,padding=0) |
|---|---|---|
| 输出尺寸 | ((H_{in} + 2p - k) / s + 1) | ((5+0-3)/1+1 = 3) |
| 参数量(不算偏置) | (C_{in} \times k_h \times k_w \times C_{out}) | 3通道输入、3x3核、16输出 = (3\times3\times3\times16=432) |
| 偏置数 | (C_{out}) | 16 |
| 输出通道数 | 卷积核个数 | 16 |
常见报错速查
| 报错关键词 | 原因 | 解决方法 |
|---|---|---|
Expected 4-dimensional input | 输入不是(N, C, H, W)形状 | 用.unsqueeze()增加dim |
size mismatch | 权重形状或输入输出配合不上 | 打印.shape逐步排查 |
device mismatch | 数据和模型在不同设备(CPU/GPU) | 统一.to('cuda')或.to('cpu') |
NaN出现在损失里 | 学习率过大或数据未归一化 | 检查输入范围,降低学习率 |
习题答题模板(原理解答题)
- 先说结论(比如“卷积通过局部加权求和提取局部特征”)
- 再分点解释:局部感知、参数共享、多核提取
- 最后配合公式或手算例子说明
- 如果有必要,写一句边界效应或padding的处理
- 不要只答名词解释,要联系卷积层的实际运算流程
这张表我建议你保存下来。以后到了深度学习大作业阶段,回来的频率会相当高。
8. 个人实操总结与后续学习建议
第一周的课和习题,说到底就是帮你建立“卷积是一种局部特征提取操作”的核心直觉,并且让你能够亲手写出来、跑起来。我在实际带人的过程中发现,凡是肯静下心把NumPy手写卷积和PyTorch卷积对比验证一遍的人,后面学BatchNorm、Dropout、ResNet时都顺很多;而那些直接调库、从不看内部实现的人,往往在第一次调参时束手无策,甚至连报错都看不懂。这也是我为什么在这篇文章里反复强调“手推手算”的原因。
最后再分享一个小技巧:学卷积别只看公式,一定要“动手加动笔”。你可以找一张老照片,先用Sobel算子画出边缘图,再用随机卷积核提取特征并对比差异。这个过程会让你真切感受到“卷积核里的参数决定了提取什么特征”,也会让你理解为什么训练网络时反向传播能自动调出好用的核。后续你可以顺着这个方向去学习Stride与Padding的进阶用法、空洞卷积(Dilated Convolution)和深度可分离卷积(Depthwise Separable Convolution),这些新花样本质上都是在“如何控制卷积核感受野和计算开销”上做文章。第一周把基础打牢,后面进入经典CNN结构(LeNet、AlexNet、VGG)时,你就能像看积木一样轻松拆解它们了。