卷积神经网络-卷积层
版权声明:署名-非商业性使用-相同方式共享
@@ Tags: 卷积神经网络;卷积层
@@ Date: 12/25/2025, 6:15:47
从数据以及算法层面上对卷积层做一个演示,理解卷积的过程以及反向传播的原理。
首先准备数据
import numpy as np
from termcolor import cprint
# 输入样本、卷积核、卷积层偏置项
sample = np.arange(16).reshape(4, 4)
filter = np.array([[-1, -2, 0], [1, 0, -2], [0, -1, 2]])
biases = np.array([5])
# 预设参数
N, channels = (1, 1)
H, W = sample.shape
FH, FW = filter.shape
padding = 0
stride = 1
out_h = (H + 2 * padding - FH) // stride + 1
out_w = (W + 2 * padding - FW) // stride + 1
cprint(f'sample:\n{sample}\n')
cprint(f'filter:\n{filter}\n', 'magenta')
输出:
sample:
[[ 0 1 2 3]
[ 4 5 6 7]
[ 8 9 10 11]
[12 13 14 15]]
filter:
[[-1 -2 0]
[ 1 0 -2]
[ 0 -1 2]]
前向传播(卷积)过程
卷积核在输入图像上滑动,将每次滑动产生卷积窗口的数据拷贝出来,以便后面矩阵乘法计算卷积。
# 滑动步数(输出特征图的每个元素都是一个滑动的卷积窗口) * 卷积窗口
matrix = np.zeros((out_h, out_w,
FH, FW), np.int64)
# 在输入数据上滑动, 拷贝每个滑动窗口的数据到矩阵中
for i in range(out_h):
for j in range(out_w):
y = i * stride
x = j * stride
y_end = y + FH
x_end = x + FW
matrix[i, j] = sample[y:y_end, x:x_end]
cprint(f'matrix:\n{matrix}\n', 'green')
输出:
32mmatrix:
[[[[ 0 1 2]
[ 4 5 6]
[ 8 9 10]]
[[ 1 2 3]
[ 5 6 7]
[ 9 10 11]]]
[[[ 4 5 6]
[ 8 9 10]
[12 13 14]]
[[ 5 6 7]
[ 9 10 11]
[13 14 15]]]]
将卷积窗口中的像素值与卷积核的权重执行点积,得到输出特征图。
# 将矩阵表示的滑动窗口的数据重塑为行格式, 以便进行矩阵乘法
matrix = matrix.reshape(-1, FH * FW)
cprint(f'matrix:\n{matrix}\n', 'cyan')
# 将卷积核矩阵重塑为列格式
weight = filter.reshape(-1).T
cprint(f'weight:\n{weight}\n', 'magenta')
# 执行矩阵乘法实现卷积
output = np.dot(matrix, weight) + biases
cprint(f'biases:\n{biases}\n', 'green')
cprint(f'output:\n{output}\n', 'green')
# 将输出重塑为特征图的形状
output = output.reshape(-1, out_h, out_w)
cprint(f'output.reshape:\n{output}\n', 'green')
输出:
matrix:
[[ 0 1 2 4 5 6 8 9 10]
[ 1 2 3 5 6 7 9 10 11]
[ 4 5 6 8 9 10 12 13 14]
[ 5 6 7 9 10 11 13 14 15]]
weight:
[-1 -2 0 1 0 -2 0 -1 2]
biases:
[5]
output:
[ 6 3 -6 -9]
output.reshape:
[[[ 6 3]
[-6 -9]]]
以上就是卷积到输出特征图的过程。为了便于理解,我们忽略了 多样本、多通道的数据维度。
前向传播的数学表达
单通道卷积(基础形式)
设输入 sample 是一个二维矩阵 $X$,卷积核为 $W$,偏置为 $b$。输出特征图 $Y$ 的每一个元素 $y_{i,j}$ 的计算公式为(步幅=1,填充=0):
$$
y_{i,j} = \sum_{m=0}^{k-1} \sum_{n=0}^{k-1} (x_{i+m, j+n} \cdot w_{m,n}) + b
$$
- $x_{i+m, j+n}$:输入图像在卷积窗口内的每一个像素。
- $w_{m,n}$:卷积核在 $(m,n)$ 位置的权重。
- $k$:卷积核的大小(假设为 $k \times k$)。
多通道卷积(实际工程形式)
在深度学习中,输入通常是三维的 通道x高x宽($C \times H \times W$),前向传播会涉及多个输入通道的累加。
假设:
- 输入 $X$ 的维度为 $(C_{in}, H_{in}, W_{in})$
- 卷积核 $W$ 的维度为 $(C_{out}, C_{in}, k, k)$
- 输出 $Y$ 的维度为 $(C_{out}, H_{out}, W_{out})$
第 $f$ 个输出特征图(Feature Map)的计算公式为:
$$
Y_f = \sum_{c=1}^{C_{in}} (X_c \star W_{f,c}) + b_f
$$
其中 $\star$ 表示二维卷积操作。这意味着:
- 每一个输出通道都是所有输入通道与对应卷积核参数卷积后的总和。
- $C_{out}$(输出通道数)本质上就是这一层所使用的卷积核(Filters/Kernels)的数量。
或者更详细的公式:
$$
Y[c_{\text{out}}, i, j] = \sum_{c_{\text{in}}=0}^{C_{\text{in}}-1} \sum_{m=0}^{k-1} \sum_{n=0}^{k-1} X[c_{\text{in}}, i+m, j+n] \cdot W[c_{\text{out}}, c_{\text{in}}, m, n] + B[c_{\text{out}}]
$$
im2col、col2im函数
经过上面的步骤,可知卷积算法的过程实际上分为两步:
- 将输入图片按照卷积窗口展开
- 将展开的数据矩阵 与 权重矩阵进行矩阵乘法
因此,提供了 im2col 和 col2im 函数,用于将输入数据进行展开和还原。
im2col函数和col2im函数的实现如下:
def im2col(input_data, filter_h, filter_w, stride=1, pad=0):
"""
将输入图像转换为列矩阵,以便于后续的矩阵乘法运算。
参数:
input_data: 输入数据,形状为(N, C, H, W)
stride : 卷积步长
pad : 零填充大小
return: 转换后的二维矩阵, 形状为(N * out_h * out_w, C * filter_h * filter_w)
"""
N, C, H, W = input_data.shape
out_h = (H + 2 * pad - filter_h) // stride + 1
out_w = (W + 2 * pad - filter_w) // stride + 1
# 填充 (Padding)
img = np.pad(input_data, [(0, 0), (0, 0),
(pad, pad), (pad, pad)], 'constant')
# 每一行的大小是: 通道数 * 卷积核高 * 卷积核宽
# 这里的形状定义只是为了方面下面的拷贝操作, 最后会进行维度重排
col = np.zeros((N, C, filter_h, filter_w, out_h, out_w))
for y in range(filter_h):
y_max = y + stride * out_h
for x in range(filter_w):
x_max = x + stride * out_w
col[:, :, y, x, :, :] = img[:, :, y:y_max:stride, x:x_max:stride]
# 转置并重塑
return col.transpose(0, 4, 5, 1, 2, 3).reshape(N * out_h * out_w, -1)
def col2im(col, input_shape, filter_h, filter_w, stride=1, pad=0):
"""
将列矩阵转换回图像格式,用于反向传播
参数:
col: 展开后的列矩阵, 形状为 (N * out_h * out_w, C * filter_h * filter_w)
input_shape: 原始数据的形状 (N, C, H, W)
返回: 重建的图像(把col中卷积窗口的值“累加”回来, 因此输出与输入是不同的: col2im(im2col(x)) ≠ x)
"""
N, C, H, W = input_shape
out_h = (H + 2 * pad - filter_h) // stride + 1
out_w = (W + 2 * pad - filter_w) // stride + 1
# 重塑并转置回原始维度顺序
col = col.reshape(N, out_h, out_w, C, filter_h,
filter_w).transpose(0, 3, 4, 5, 1, 2)
img = np.zeros((N, C, H + 2 * pad + stride - 1, W + 2 * pad + stride - 1))
for y in range(filter_h):
y_max = y + stride * out_h
for x in range(filter_w):
x_max = x + stride * out_w
# 关键:累加重叠部分的梯度
img[:, :, y:y_max:stride, x:x_max:stride] += col[:, :, y, x, :, :]
# 切除 padding 部分
return img[:, :, pad:H + pad, pad:W + pad]
images = sample.reshape(1, 1, H, W)
cprint(f'images:\n{images}\n')
col = im2col(images, FH, FW)
cprint(f'col:\n{col}\n')
img = col2im(col, images.shape, FH, FW)
cprint(f'img:\n{img}\n', 'yellow')
输出:
images:
[[[[ 0 1 2 3]
[ 4 5 6 7]
[ 8 9 10 11]
[12 13 14 15]]]]
col:
[[ 0. 1. 2. 4. 5. 6. 8. 9. 10.]
[ 1. 2. 3. 5. 6. 7. 9. 10. 11.]
[ 4. 5. 6. 8. 9. 10. 12. 13. 14.]
[ 5. 6. 7. 9. 10. 11. 13. 14. 15.]]
img:
[[[[ 0. 2. 4. 3.]
[ 8. 20. 24. 14.]
[16. 36. 40. 22.]
[12. 26. 28. 15.]]]]
观察images到img的结果可以发现:
中间像素被卷积窗口覆盖了多次,在 col2im 中被多次累加了。 而边角像素只被覆盖一次,所以它们是一样的。
因此, 对于边缘非边角元素被累加两次, 中间元素被累加四次。
关于输出的形状, 可能与其他实现存在差异,但不影响逻辑。
再看看完整的RGB图像的数据样本:
cprint(f'RGB三通道图片:\n')
C = 3 # RGB 三通道
img = np.arange(1*C*H*W).reshape(1, C, H, W)
col = im2col(img, FH, FW)
# cprint(f'原始数据:\n{img}\n')
cprint(f'卷积窗口数据:\n{col}\n', 'green')
FN = 2 # 卷积核个数
kernel = np.arange(FN*C*FH*FW).reshape(FN, C, FH, FW)
weight = kernel.reshape(FN, -1).T
# cprint(f'卷积核:\n{kernel}\n', 'red')
# cprint(f'权重值:\n{weight}\n', 'red')
output = np.dot(col, weight)
cprint(f'特征数据:\n{output}\n', 'yellow')
# 转换为特征图
out_h, out_w = (2, 2) # 特征图大小
output = output.reshape(N, out_h, out_w, -1)
output = output.transpose(0, 3, 1, 2)
cprint(f'特征图:\n{output}\n', 'red')
输出:
RGB三通道图片:
卷积窗口数据:
[[ 0. 1. 2. 4. 5. 6. 8. 9. 10. 16. 17. 18. 20. 21. 22. 24. 25. 26.
32. 33. 34. 36. 37. 38. 40. 41. 42.]
[ 1. 2. 3. 5. 6. 7. 9. 10. 11. 17. 18. 19. 21. 22. 23. 25. 26. 27.
33. 34. 35. 37. 38. 39. 41. 42. 43.]
[ 4. 5. 6. 8. 9. 10. 12. 13. 14. 20. 21. 22. 24. 25. 26. 28. 29. 30.
36. 37. 38. 40. 41. 42. 44. 45. 46.]
[ 5. 6. 7. 9. 10. 11. 13. 14. 15. 21. 22. 23. 25. 26. 27. 29. 30. 31.
37. 38. 39. 41. 42. 43. 45. 46. 47.]]
特征数据:
[[10197. 25506.]
[10548. 26586.]
[11601. 29826.]
[11952. 30906.]]
特征图:
[[[[10197. 10548.]
[11601. 11952.]]
[[25506. 26586.]
[29826. 30906.]]]]
对比上一个单通道数据, 可以看出 im2col 函数输出的二维数据中, 每一行 表示样本在卷积核中滑动时的窗口数据。
如果有多个通道, 则同时包含多个通道的窗口数据。 本质上就是为了满足后面的矩阵运算, 因此需要先构建数据。
再看特征数据部分, 每一行是卷积核在当前滑动的卷积窗口中的特征值, 列是不同的卷积核。
通过特征数据变形再重排后, 就可以得到卷积核对应的特征图了。
特征图浓缩了整个输入样本相对于卷积核的特性信息,相当于数据被卷积核筛选出来的结果,因此卷积核又叫过滤器。
卷积层的实现
class ConvolutionalLayer:
"""卷积层实现"""
def __init__(self, weights, biases, stride=1, padding=0):
"""
初始化卷积层
参数:
weights : 卷积核权重,形状为(输出通道数, 输入通道数, 高度, 宽度)
biases : 偏置项,形状为(输出通道数,)
stride : 卷积步长
padding : 零填充大小
"""
self.weights = weights
self.biases = biases
self.stride = stride
self.padding = padding
# 中间变量(用于反向传播)
self.x = None
self.col = None
self.colWeights = None
# 参数的梯度
self.dweights = None
self.dbiases = None
def forward(self, input_data):
"""
前向传播
参数:
input_data : 输入数据,形状为(样本大小, 通道数, 高度, 宽度)
返回:
output : 卷积核对应的特征图,形状为(N, FN, out_h, out_w)
"""
FN, C, FH, FW = self.weights.shape
N, C, H, W = input_data.shape
# 计算输出特征图的大小
out_h = (H + 2 * self.padding - FH) // self.stride + 1
out_w = (W + 2 * self.padding - FW) // self.stride + 1
# 将输入图像转换为行矩阵
row_matrix = im2col(input_data, FH, FW, self.stride, self.padding)
# 将权重矩阵重塑为列格式
column_matrix = self.weights.reshape(FN, -1).T
# 执行矩阵乘法实现卷积
output = np.dot(row_matrix, column_matrix) + self.biases
# 将输出重塑为正确的4维格式
output = output.reshape(N, out_h, out_w, -1)
output = output.transpose(0, 3, 1, 2)
# 保存中间变量用于反向传播
self.x = input_data
self.col = row_matrix
self.colWeights = column_matrix
return output
def backward(self, dout):
"""
反向传播
参数:
dout : 输出层的梯度, 形状(N, FN, out_h, out_w)
返回:
gradient_input : 输入层的梯度
"""
FN, C, FH, HW = self.weights.shape
# 调整梯度输出的形状, 通道调整到最后
dout = dout.transpose(0, 2, 3, 1).reshape(-1, FN)
# 计算偏置项的梯度, 每个特征图的梯度求和作为该卷积核的偏置
self.dbiases = np.sum(dout, axis=0)
# 计算权重的梯度
self.dweights = np.dot(self.col.T, dout)
self.dweights = self.dweights.transpose(1, 0).reshape(FN,
C,
FH,
HW)
# 计算输入数据的梯度
dcol = np.dot(dout, self.colWeights.T)
dx = col2im(dcol,
self.x.shape,
FH,
HW,
self.stride,
self.padding)
return dx
# -------------------------------------------------------------------
# 构造输入
x = np.random.randn(2, 3, 5, 5) # N=2, C=3
w = np.random.randn(4, 3, 3, 3) # FN=4
b = np.random.randn(4)
conv = ConvolutionalLayer(w, b, stride=1, padding=1)
# 前向传播
out = conv.forward(x)
# 注意输出格式为: (N, FN, out_h, out_w)
# 这表示每个样本的
print("output shape:", out.shape) # (2, 4, 5, 5)
# 构造上游梯度
dout = np.random.randn(*out.shape)
# backward
dx = conv.backward(dout)
print("dx shape:", dx.shape)
print("dw shape:", conv.dweights.shape)
print("db shape:", conv.dbiases.shape)
输出:
output shape: (2, 4, 5, 5)
dx shape: (2, 3, 5, 5)
dw shape: (4, 3, 3, 3)
db shape: (4,)
def numerical_gradient(conv, x, dout, eps=1e-5):
grad_w = np.zeros_like(conv.weights)
it = np.nditer(conv.weights, flags=['multi_index'], op_flags=['readwrite'])
while not it.finished:
idx = it.multi_index
old = conv.weights[idx]
conv.weights[idx] = old + eps
out1 = conv.forward(x)
loss1 = np.sum(out1 * dout)
conv.weights[idx] = old - eps
out2 = conv.forward(x)
loss2 = np.sum(out2 * dout)
grad_w[idx] = (loss1 - loss2) / (2 * eps)
conv.weights[idx] = old
it.iternext()
return grad_w
# 数值梯度 vs 反向传播
num_dw = numerical_gradient(conv, x, dout)
max_diff = np.max(np.abs(num_dw - conv.dweights))
print("max diff:", max_diff)
输出:
max diff: 2.0845973836003395e-09
反向传播的数学表达
反向传播的主要目标是计算两个梯度:
- 损失函数对输入的梯度(用于将误差传递给前一层)。
- 损失函数对卷积核参数的梯度(用于更新当前层的权重)。
在卷积层的前向传播中,输入矩阵 $X \in \mathbb{R}^{C_{\text{in}} \times H_{\text{in}} \times W_{\text{in}}}$ 与卷积核 $W \in \mathbb{R}^{C_{\text{out}} \times C_{\text{in}} \times k_H \times k_W}$ 进行卷积得到输出 特征图 $Y \in \mathbb{R}^{C_{\text{out}} \times H_{\text{out}} \times W_{\text{out}}}$。
在反向传播时,我们已经从下一层拿到了输出梯度($\frac{\partial L}{\partial Y}$),通常被称为“误差项”或 $\delta$。
$$
\frac{\partial L}{\partial Y} \in \mathbb{R}^{C_{\text{out}} \times H_{\text{out}} \times W_{\text{out}}}
$$
设误差或损失函数为:
$$
L = Loss(Y)
$$
根据 前向传播的公式:
$$
Y = \sum_{c=1}^{C_{in}} (X \star W) + b
$$
分别计算 $\frac{\partial L}{\partial W}$ 、$\frac{\partial L}{\partial X}$ 和 $\frac{\partial L}{\partial b}$。
对权重 $W$ 求导(更新参数)
根据链式法则,对于权重 $W[c_{\text{out}}, c_{\text{in}}, i, j]$:
$$
\frac{\partial L}{\partial W[c_{\text{out}}, c_{\text{in}}, i, j]} = \sum_{m=0}^{H_{\text{out}}-1} \sum_{n=0}^{W_{\text{out}}-1} \frac{\partial L}{\partial Y[c_{\text{out}}, m, n]} \cdot \frac{\partial Y[c_{\text{out}}, m, n]}{\partial W[c_{\text{out}}, c_{\text{in}}, i, j]}
$$
从前向公式可知:
$$
\frac{\partial Y[c_{\text{out}}, m, n]}{\partial W[c_{\text{out}}, c_{\text{in}}, i, j]} = X[c_{\text{in}}, m+i, n+j]
$$
因此:
$$
\frac{\partial L}{\partial W[c_{\text{out}}, c_{\text{in}}, i, j]} = \sum_{m=0}^{H_{\text{out}}-1} \sum_{n=0}^{W_{\text{out}}-1} \frac{\partial L}{\partial Y[c_{\text{out}}, m, n]} \cdot X[c_{\text{in}}, m+i, n+j]
$$
3.2 向量化形式(互相关运算)
这等价于对每个输出通道 $c_{\text{out}}$ 和输入通道 $c_{\text{in}}$,执行以下互相关运算:
$$
\frac{\partial L}{\partial W[c_{\text{out}}, c_{\text{in}}]} = X[c_{\text{in}}] \star \frac{\partial L}{\partial Y[c_{\text{out}}]}
$$
完整公式:
$$
\boxed{\frac{\partial L}{\partial W} = X \star \frac{\partial L}{\partial Y}}
$$
其中 $\star$ 是互相关运算,维度变化:
- $X$: $(C_{\text{in}}, H_{\text{in}}, W_{\text{in}})$
- $\frac{\partial L}{\partial Y}$: $(C_{\text{out}}, H_{\text{out}}, W_{\text{out}})$
- $\frac{\partial L}{\partial W}$: $(C_{\text{out}}, C_{\text{in}}, k_H, k_W)$
对输入 $X$ 求导(传递误差)
为了让误差继续向前传播,我们需要计算 $\frac{\partial L}{\partial X}$。
对于输入元素 $X[c_{\text{in}}, h_x, w_x]$:
$$
\frac{\partial L}{\partial X[c_{\text{in}}, h_x, w_x]} = \sum_{c_{\text{out}}=0}^{C_{\text{out}}-1} \sum_{h=0}^{H_{\text{out}}-1} \sum_{w=0}^{W_{\text{out}}-1} \frac{\partial L}{\partial Y[c_{\text{out}}, h, w]} \cdot \frac{\partial Y[c_{\text{out}}, h, w]}{\partial X[c_{\text{in}}, h_x, w_x]}
$$
注意:
- $h$、$w$ 表示输出特征图的行、列,也可以视为滑动卷积窗口在输入元素内部的偏移量。
- $h_x$、$w_x$ 表示输入元素内的实际坐标。
- $k_H$、$k_W$ 表示卷积核的宽、高。
从前向公式可知,只有当满足以下条件时,偏导数非零:
$$
\frac{\partial Y[c_{\text{out}}, h, w]}{\partial X[c_{\text{in}}, h_x, w_x]} =
\begin{cases}
W[c_{\text{out}}, c_{\text{in}}, h_x-h, w_x-w] & \text{if } 0 \leq h_x-h < k_H \text{ and } 0 \leq w_x-w < k_W \\
0 & \text{otherwise}
\end{cases}
$$
令 $i = h_x - h$, $j = w_x - w$,则:
$$
\frac{\partial L}{\partial X[c_{\text{in}}, h_x, w_x]} = \sum_{c_{\text{out}}=0}^{C_{\text{out}}-1} \sum_{i=0}^{k_H-1} \sum_{j=0}^{k_W-1} \frac{\partial L}{\partial Y[c_{\text{out}}, h_x-i, w_x-j]} \cdot W[c_{\text{out}}, c_{\text{in}}, i, j]
$$
注意:$i$、$j$ 可以看作是滑动卷积窗口内部元素距离窗口左上角的偏移,因此它们的取值范围是 $[0, K]$。
4.2 向量化形式(全卷积运算)
这等价于一个全卷积(full convolution)运算:
$$
\frac{\partial L}{\partial X[c_{\text{in}}]} = \sum_{c_{\text{out}}=0}^{C_{\text{out}}-1} \text{full_conv}\left( \frac{\partial L}{\partial Y[c_{\text{out}}]}, \text{rot180}(W[c_{\text{out}}, c_{\text{in}}]) \right)
$$
其中 $\text{rot180}(\cdot)$ 表示将矩阵旋转180度(即上下翻转+左右翻转),$\text{full_conv}$ 表示全卷积。
等价形式(使用互相关和填充):
$$
\boxed{\frac{\partial L}{\partial X} = \text{pad}\left(\frac{\partial L}{\partial Y}\right) \star \text{rot180}(W)}
$$
或者使用转置卷积表示:
$$
\boxed{\frac{\partial L}{\partial X} = \text{transposed_conv}\left(\frac{\partial L}{\partial Y}, W\right)}
$$
维度变化:
- $\frac{\partial L}{\partial Y}$: $(C_{\text{out}}, H_{\text{out}}, W_{\text{out}})$
- $W$: $(C_{\text{out}}, C_{\text{in}}, k_H, k_W)$
- $\frac{\partial L}{\partial X}$: $(C_{\text{in}}, H_{\text{in}}, W_{\text{in}})$
对偏置 $b$ 求导
偏置项的计算最简单。由于一个卷积核通常共享一个偏置,因此偏置的梯度就是对应输出梯度图(Feature Map)中所有元素的总和。
如果前向传播包含偏置:$Y = X \star W + b$,其中 $b \in \mathbb{R}^{C_{\text{out}}}$,则:
$$
\frac{\partial L}{\partial b[c_{\text{out}}]} = \sum_{m=0}^{H_{\text{out}}-1} \sum_{n=0}^{W_{\text{out}}-1} \frac{\partial L}{\partial Y[c_{\text{out}}, m, n]} \cdot \frac{\partial Y[c_{\text{out}}, m, n]}{\partial b[c_{\text{out}}]}
$$
由于 $\frac{\partial Y[c_{\text{out}}, m, n]}{\partial b[c_{\text{out}}]} = 1$:
$$
\boxed{\frac{\partial L}{\partial b[c_{\text{out}}]} = \sum_{m=0}^{H_{\text{out}}-1} \sum_{n=0}^{W_{\text{out}}-1} \frac{\partial L}{\partial Y[c_{\text{out}}, m, n]}}
$$
向量化形式:
$$
\boxed{\frac{\partial L}{\partial b} = \sum_{m,n} \frac{\partial L}{\partial Y[:, m, n]}}
$$
Comments ()