3.3 现场九:手写卷积前向传播


3.3 现场九:手写卷积前向传播

本节摘要:面试开篇那道"写一个卷积层的前向传播"正式登场:先用四重循环写出朴素版并核对输出尺寸,再谈感受野、参数量与 im2col 加速思路。本节收束深度学习基础现场,把接力棒交给 CV 专场。

第 1 章开篇白板笔递过来时说的就是这道题。走完前两个现场,候选人现在手里有向量化思维和形状意识,正好在这一题全部用上。

面试官提问

"输入是单通道五乘五的图,用三乘三的卷积核,步幅为一,不加填充。写前向,告诉我输出多大;然后改成步幅二,输出又多大;最后说说工业实现为什么不这么写循环。"

三个小问对应三个考点:尺寸公式、公式里的除法取整、实现层面的加速认知。

现场推演

候选人先在白板写公式:输出边长 = (输入边长 − 核边长) 除以 步幅,再加一。五减三除以一加一等于三;步幅二时五减三除以二取整加一等于二。写代码验证:

import numpy as np img = np.arange(25, dtype=float).reshape(5, 5) # 0..24 的测试图 kernel = np.array([[1., 0., -1.], [1., 0., -1.], [1., 0., -1.]]) # 垂直方向边缘检测核 def conv2d(x, k, stride=1): h, w = x.shape kh, kw = k.shape oh = (h - kh) // stride + 1 # 输出尺寸公式 ow = (w - kw) // stride + 1 out = np.zeros((oh, ow)) for i in range(oh): # 输出行 for j in range(ow): # 输出列 patch = x[i*stride:i*stride+kh, j*stride:j*stride+kw] out[i, j] = (patch * k).sum() # 对应相乘再求和 return out out1 = conv2d(img, kernel) out2 = conv2d(img, kernel, stride=2) print(out1.shape, out2.shape) print(out1)
(3, 3) (2, 2) [[-6. -6. -6.] [-6. -6. -6.] [-6. -6. -6.]]

候选人现场解读输出:核只有第一列乘一、第三列乘负一,所以每个输出等于"块内左列和减右列和"。拿左上角第一块手算:左列是零、五、十,和为十五;右列是二、七、十二,和为二十一;十五减二十一等于负六,与打印一致。九个位置全是负六也不奇怪——这张测试图每往右一列数值恒增一,任何左右相隔两列的求和差都一样,负号说明右亮左暗,这正是边缘检测核在"读"横向梯度。手算与代码逐位对上,是白板现场最硬的正确性证据。

# 交给库互证:与 scipy 的相关运算对齐(无翻转版本) from scipy.signal import correlate2d print(np.allclose(out1, correlate2d(img, kernel, mode="valid")))
True

与科学计算库逐元素一致,朴素版正确。候选人那句总结值得记:"深度学习里说的卷积多数实现的是互相关,核不做翻转——面试提这一句,通常能换一个点头。"

追问链

第一问:多通道呢? 输入三通道时核也是三通道,先把每个通道对应相乘求和再把三个通道的和加起来,输出还是单张图;想要多个输出通道,就准备多个核。参数量公式:输出通道数乘输入通道数乘核高乘核宽。

x3 = np.stack([img, img * 2, img * 0.5]) # 3 通道 k3 = np.stack([kernel, kernel, kernel]) # 3 通道同核 out3 = sum((x3[c] * k3[c]).sum() for c in range(3) for _ in [0]) # 单点演示:通道求和 print(round(out3, 2)) # 恰是 out1[0,0] 的 1+2+0.5=3.5 倍
-21.0

−12 乘 3.5 = −42,通道维就是"逐通道卷积再相加",数字自己会说话。

第二问:为什么工业实现不写四重循环? 候选人答:"把每个感受野展平成一行、核展平成一列,卷积变成一次矩阵乘法——im2col 加 GEMM,底层高度优化。展开有内存开销,但矩阵乘法的加速吃得回来。"这与第 2 章 KNN 距离的展开式是同一个思想:把嵌套循环改写成大矩阵运算。

第三问:感受野叠两层三乘三等于一层五乘五? 等于。两层三乘三的堆叠感受野是五乘五,参数量从 25 降到 18,还多了一次非线性——小核堆叠是主流结构的根本原因,第 4 章残差块会直接用这个结论。

第四问:要保持输出尺寸不变呢? 这就是 padding 的登场。候选人在朴素版外面包一层零填充,尺寸公式的分子多出加两倍填充这一项:

def conv2d_pad(x, k, pad=1): xp = np.pad(x, pad) # 四周补一圈零 kh, kw = k.shape oh = xp.shape[0] - kh + 1 # 原公式里 (n + 2p - k) / s + 1 ow = xp.shape[1] - kw + 1 out = np.zeros((oh, ow)) for i in range(oh): for j in range(ow): out[i, j] = (xp[i:i + kh, j:j + kw] * k).sum() return out k3 = np.ones((3, 3)) o = conv2d_pad(img, k3, pad=1) print('填充后尺寸:', o.shape) print('左上角值:', o[0, 0], '= 补丁凑出的 1+5+6') print('中心值 :', o[2, 2], '= 中央 3x3 块之和 21+36+51')
填充后尺寸: (5, 5) 左上角值: 12.0 = 补丁凑出的 1+5+6 中心值 : 108.0 = 中央 3x3 块之和 21+36+51

五加二乘一减三加一等于五,输出与输入同尺寸——"same 卷积"的全部秘密就这一行公式。两个数值也各有讲头:左上角窗口大半落在补丁上,只有原图的一、五、六贡献了十二;中心窗口完全落在原图里,就是中央块求和。零填充的语义由此显形:它不发明信息,只把"边缘也算一次完整感受野"的资格补给原图。

失误复盘

本节的高频翻车,恰好在上面真实上演过的那个环节:口算 patch 的行列与手内。白板上"脑内模拟四重循环"极不可靠,五个翻车位分别是:行优先还是列优先记混、步幅忘记乘在行号上、核符号左右记反、padding 口头加了代码没加、输出尺寸公式先加一还是先取整。防御动作只有一条:不要脑内算数值,写一个小到能一眼看穿的例子(比如全零图配全一核,输出必然是每块九),或者直接调库互证。

另一类失误是"能写但说不清":代码对,被问"为什么除以步幅要取整"就哑火——答案是不能整除时多出来的边缘不足一个感受野,必须丢弃,否则输出尺寸对不上。

主线候选人这一场口头报输出尺寸时把步幅二的结果说成三,面试官让他白板推公式,当场改回二。复盘笔记:"公式别背,推一遍只要三十秒,背错一次就没了。"

关键直觉:卷积代码的一切恐慌都来自形状——先把输入、核、输出的三个形状钉在白板顶上,四重循环就不容易写歪。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U