3692 字
18 分钟
从零手搓一个卷积神经网络

(后注:这个顺序应该是错了的,但是问题是我跟着老师讲的顺序搓的,so …)

写完线性回归之后,老板又给我扔了个新活:手写一个卷积神经网络(CNN)。

虽然知道这玩意儿在深度学习框架里就是几行代码的事,但手写一遍能让人真正理解卷积核是怎么滑动的、梯度是怎么回传的。

累是累了点,但至少能在面试时拍着胸脯说:“我真的懂 CNN。“

什么是卷积神经网络#

卷积神经网络(CNN)是专门用来处理图像数据的神经网络。它的核心操作是卷积,用一个小的卷积核在图像上滑动,提取局部特征。

一个最简单的 CNN 通常包括:

  1. 卷积层:用卷积核提取特征
  2. 激活函数:引入非线性(比如 ReLU)
  3. 全连接层:把特征映射到最终输出

今天我们要实现的就是这样一个最简单的 CNN:一个 3×3 卷积核 + ReLU + 全连接层。

NOTE

这个 CNN 简单到只能算是个玩具模型,但它包含了 CNN 的所有核心组件。理解了这个,再去看那些复杂的架构就不会觉得太吓人了。

准备数据#

数据是一个 5×5 的图像,标签是 1。

import numpy as np
np.random.seed(0)
X = np.array([
[0, 1, 1, 0, 0],
[1, 1, 1, 0, 0],
[0, 1, 1, 1, 0],
[0, 0, 1, 1, 1],
[0, 0, 0, 1, 0],
], dtype=np.float32)
y_true = np.array([[1.0]])

这个图像看起来像一个斜着的”十”字。虽然只有一个样本,但对于演示 CNN 的工作原理来说已经够了。

TIP

在真实场景里,单个样本肯定不够训练一个模型。但这里的目标是理解 CNN 的机制,而不是训练一个能用的分类器。

初始化参数#

我们需要两组参数:

  1. 卷积层参数:一个 3×3 的卷积核 W_conv 和一个偏置 b_conv
  2. 全连接层参数:权重 W_fc 和偏置 b_fc
W_conv = np.random.randn(3, 3) * 0.1
b_conv = 0.0
fc_in_dim = 3 * 3 # 卷积后的输出是 3×3
W_fc = np.random.randn(fc_in_dim, 1) * 0.1
b_fc = 0.0

卷积核随机初始化,偏置从零开始。这和线性回归的初始化逻辑一样。

实现卷积操作#

卷积的核心思想是:用一个小的卷积核在图像上滑动,每次滑到一个位置就做一次点乘求和。

对于一个 5×5 的图像和 3×3 的卷积核,输出是一个 3×3 的特征图。

def conv2d(x, w):
out = np.zeros((3, 3))
for i in range(3):
for j in range(3):
region = x[i:i+3, j:j+3] # 提取 3×3 的区域
out[i, j] = np.sum(region * w) # 点乘求和
return out

这个函数做的事情很简单:

  1. 从输入图像中提取一个 3×3 的区域
  2. 把这个区域和卷积核做逐元素相乘
  3. 把所有元素求和,存到输出的对应位置
IMPORTANT

这里没有使用 padding 和 stride。默认情况下,卷积核每次滑动 1 步,不填充边界。所以 5×5 的输入经过 3×3 的卷积核后,输出是 3×3。

卷积的数学表达#

用数学符号写出来就是:

out[i, j] = Σ Σ x[i+m, j+n] · w[m, n]

其中 m 和 n 分别从 0 到 2(卷积核的大小)。

实现激活函数(ReLU)#

ReLU(Rectified Linear Unit)是最常用的激活函数,公式很简单:

ReLU(x) = max(0, x)

把负数变成零,正数保持不变。

relu = np.maximum(conv, 0)

为什么需要激活函数?因为如果只有线性操作(卷积、全连接),无论堆多少层,最终还是一个线性模型。激活函数引入了非线性,让模型能拟合更复杂的函数。

实现全连接层#

全连接层就是普通的线性变换:

y = W · x + b

先把 3×3 的特征图展平成一个 9 维的向量,然后做矩阵乘法。

flat = relu.reshape(-1, 1) # 展平成 (9, 1)
y_pred = W_fc.T @ flat + b_fc # 全连接层

这一步和线性回归完全一样。

前向传播#

把上面的所有操作串起来,就是完整的前向传播:

conv = conv2d(X, W_conv) + b_conv # 卷积
relu = np.maximum(conv, 0) # ReLU 激活
flat = relu.reshape(-1, 1) # 展平
y_pred = W_fc.T @ flat + b_fc # 全连接层

输入一个 5×5 的图像,输出一个标量(预测值)。

计算损失#

损失函数用的是均方误差(MSE):

loss = (y_pred - y_true) ** 2

这和线性回归一样。目标是让预测值尽可能接近真实标签。

反向传播:计算梯度#

反向传播是 CNN 训练的核心,也是最难理解的部分。我们需要计算损失函数对每个参数的梯度。

全连接层的梯度#

先算全连接层的梯度。这部分和线性回归一样。

dL_dy = 2 * (y_pred - y_true) # 损失对输出的梯度
dL_dW_fc = flat * dL_dy # 损失对 W_fc 的梯度
dL_db_fc = dL_dy # 损失对 b_fc 的梯度
dL_dflat = W_fc * dL_dy # 损失对 flat 的梯度

ReLU 的梯度#

ReLU 的梯度很简单:

∂ReLU/∂x = 1 if x > 0 else 0

dL_drelu = dL_dflat.reshape(3, 3) # 把梯度 reshape 回 3×3
dL_dconv = dL_drelu * (conv > 0) # ReLU 的梯度

只有输入大于零的地方,梯度才能回传。这叫做”梯度门控”。

卷积层的梯度#

卷积层的梯度是最复杂的部分。我们需要计算损失对卷积核的梯度。

推导过程(简化版):

∂L/∂W_conv[m, n] = Σ Σ ∂L/∂conv[i, j] · x[i+m, j+n]

用代码实现:

def conv2d_grad(x, grad_out):
dW = np.zeros_like(W_conv)
for i in range(3):
for j in range(3):
region = x[i:i+3, j:j+3] # 提取对应区域
dW += grad_out[i, j] * region # 累加梯度
return dW
dL_dW_conv = conv2d_grad(X, dL_dconv)
dL_db_conv = np.sum(dL_dconv) # 偏置的梯度是所有梯度的和
WARNING

这里的梯度计算是手工推导的。如果卷积核的形状、步长、padding 不同,梯度的计算方式也会不同。深度学习框架里有自动求导,不用自己算这些。

参数更新#

有了梯度之后,就可以更新参数了。

W_fc -= lr * dL_dW_fc
b_fc -= lr * dL_db_fc
W_conv -= lr * dL_dW_conv
b_conv -= lr * dL_db_conv

这和线性回归完全一样。把参数往梯度的反方向挪一点。

完整训练循环#

把上面的所有步骤串起来:

import tqdm
lr = 0.01
step = 0
loss = 999
loss_list = []
pbar = tqdm.tqdm(total=20000, desc="Training")
while loss > 1e-6 and step < 20000:
# 前向传播
conv = conv2d(X, W_conv) + b_conv
relu = np.maximum(conv, 0)
flat = relu.reshape(-1, 1)
y_pred = W_fc.T @ flat + b_fc
# 计算损失
loss = (y_pred - y_true) ** 2
loss_list.append(loss[0][0])
# 反向传播
dL_dy = 2 * (y_pred - y_true)
dL_dW_fc = flat * dL_dy
dL_db_fc = dL_dy
dL_dflat = W_fc * dL_dy
dL_drelu = dL_dflat.reshape(3, 3)
dL_dconv = dL_drelu * (conv > 0)
dL_dW_conv = conv2d_grad(X, dL_dconv)
dL_db_conv = np.sum(dL_dconv)
# 参数更新
W_fc -= lr * dL_dW_fc
b_fc -= lr * dL_db_fc
W_conv -= lr * dL_dW_conv
b_conv -= lr * dL_db_conv
# 更新进度条
pbar.set_postfix({"loss": f"{loss[0][0]:.6e}"})
pbar.update(1)
step += 1
pbar.close()

训练会一直跑,直到损失降到 1e-6 以下,或者达到 20000 步上限。

训练结果#

训练只用了 29 步就收敛了。对,你没看错,就 29 步。

Training: 0%| 29/20000 [00:00<00:01, 11789.75it/s, loss=5.702757e-07]
Training finished.
Final loss: 5.702757489462377e-07
Conv kernel:
[[ 0.24261854 0.09054294 0.13766079]
[ 0.26846072 0.24176568 -0.05274481]
[ 0.12720523 0.02923568 0.04382874]]
FC weights: [0.10302946 0.10564559 0.20719969 0.13388084 0.08347397 0.12932452
0.04650248 0.20718497 0.04966203]
Prediction: 0.9992448339593532
Label: 1.0

让我们看看结果:

最终损失: 5.7×10⁻⁷
预测值: 0.9992448
真实标签: 1.0

预测值和真实标签的误差只有 0.0007552,基本上可以算是完美拟合了。

NOTE

为什么这么快就收敛了?因为数据太简单了。只有一个样本,模型很快就能”记住”这个样本的特征。在真实场景里,有成千上万个样本,收敛会慢得多。

学到的卷积核#

训练完成后的卷积核长这样:

[[ 0.243 0.091 0.138]
[ 0.268 0.242 -0.053]
[ 0.127 0.029 0.044]]

这些数值看起来没什么规律,但它们是模型学到的”特征提取器”。这个卷积核在输入图像上滑动,提取出能让预测值接近 1 的特征。

全连接层的权重也学到了对应的映射:

[0.103 0.106 0.207 0.134 0.083 0.129 0.047 0.207 0.050]

这 9 个权重对应卷积后 3×3 特征图的 9 个位置。权重大的位置说明那个位置的特征对最终预测更重要。

可视化损失曲线#

import matplotlib.pyplot as plt
plt.figure(figsize=(6, 4))
plt.plot(loss_list)
plt.xlabel("Step")
plt.ylabel("Loss (MSE)")
plt.title("CNN Training Loss Curve")
plt.grid(True)
plt.tight_layout()
plt.show()

损失曲线会从一个比较高的值(初始随机参数导致的)快速下降到接近零。由于只训练了 29 步,曲线会非常短,但能清楚地看到快速收敛的过程。

可视化卷积过程#

我们可以把卷积前后的结果画出来,直观地看看卷积核提取了什么特征。

conv_final = conv2d(X, W_conv)
relu_final = np.maximum(conv_final, 0)
plt.figure(figsize=(10, 4))
plt.subplot(1, 3, 1)
plt.imshow(X, cmap="gray")
plt.title("Input Image")
plt.axis("off")
plt.subplot(1, 3, 2)
plt.imshow(conv_final, cmap="gray")
plt.title("Conv Output")
plt.axis("off")
plt.subplot(1, 3, 3)
plt.imshow(relu_final, cmap="gray")
plt.title("ReLU Output")
plt.axis("off")
plt.tight_layout()
plt.show()

这三张图分别是:

  1. 输入图像:原始的 5×5 图像(斜十字形状)
  2. 卷积输出:经过卷积核后的 3×3 特征图
  3. ReLU 输出:经过 ReLU 激活后的结果(负值被置零)

从这些图可以看出,卷积核提取了图像的某些局部特征。ReLU 把负值去掉后,特征图变得更稀疏。

TIP

在真实的 CNN 中,通常会用多个卷积核(比如 32 个、64 个),每个卷积核提取不同的特征。这里为了简单只用了一个。

CNN 的核心思想#

CNN 和普通神经网络的最大区别在于局部连接权值共享

局部连接

  • 普通神经网络中,每个神经元都和上一层的所有神经元连接。
  • CNN 中,卷积核只和图像的一小块区域连接(比如 3×3)。

权值共享

  • 同一个卷积核在整个图像上滑动,参数是共享的。
  • 这大大减少了参数数量。如果用全连接层,需要 5×5×3×3 = 225 个参数,但卷积核只需要 3×3 = 9 个参数。

这两个特性让 CNN 非常适合处理图像数据:

  • 平移不变性:同一个特征出现在图像的不同位置,都能被识别。
  • 参数高效:参数少,训练快,不容易过拟合。

为什么要手写 CNN#

现在的深度学习框架(PyTorch、TensorFlow)都有现成的卷积层,为什么还要手写?

因为:

  • 理解卷积的本质:知道卷积核是怎么滑动的、梯度是怎么回传的。
  • 调试能力:遇到问题时,能快速定位是哪一步出错了。
  • 面试需要:很多面试官会问”能不能手写一个卷积操作”或”解释一下卷积的反向传播”。

虽然累,但至少能证明你真的懂 CNN,而不是只会调 API。

CNN 的局限性#

虽然这个 CNN 能跑通,但它有很多局限性:

只有一个样本

  • 真实场景需要大量数据才能训练好模型。单样本训练只是”记忆”而不是”学习”。

只有一个卷积核

  • 真实的 CNN 会用几十个甚至几百个卷积核,提取不同的特征(边缘、纹理、形状等)。

没有池化层

  • 池化层(Max Pooling、Average Pooling)可以降低特征图的尺寸,减少计算量,增强平移不变性。

没有批量处理

  • 真实训练时会用 mini-batch,一次处理多个样本,提高训练效率。

没有正则化

  • 没有 Dropout、L2 正则化等防止过拟合的机制。

这些在深度学习框架里都有现成的实现。但如果你连最基础的卷积都没写过,用那些高级功能也只是调包而已。

从玩具到实用#

这个玩具 CNN 虽然简单,但它包含了所有核心组件。真实的 CNN(比如 LeNet、AlexNet、VGG、ResNet)本质上就是在这个基础上:

  1. 堆叠更多层:多个卷积层 + 池化层 + 全连接层
  2. 增加卷积核数量:第一层 32 个,第二层 64 个,第三层 128 个……
  3. 添加批归一化:加速训练,稳定梯度
  4. 使用更好的优化器:Adam、RMSprop 代替普通的 SGD
  5. 数据增强:旋转、翻转、裁剪,增加数据多样性

但核心思想没变:用卷积提取特征,用梯度下降优化参数。

完整代码#

把所有代码整合在一起:

import numpy as np
import tqdm
import matplotlib.pyplot as plt
np.random.seed(0)
# 数据:一个简单的 5x5 图像 + 标签
X = np.array([
[0, 1, 1, 0, 0],
[1, 1, 1, 0, 0],
[0, 1, 1, 1, 0],
[0, 0, 1, 1, 1],
[0, 0, 0, 1, 0],
], dtype=np.float32)
y_true = np.array([[1.0]])
# 参数:一个 3x3 卷积核 + 全连接层
W_conv = np.random.randn(3, 3) * 0.1
b_conv = 0.0
fc_in_dim = 3 * 3
W_fc = np.random.randn(fc_in_dim, 1) * 0.1
b_fc = 0.0
lr = 0.01
step = 0
loss = 999
loss_list = []
# 卷积操作
def conv2d(x, w):
out = np.zeros((3, 3))
for i in range(3):
for j in range(3):
region = x[i:i+3, j:j+3]
out[i, j] = np.sum(region * w)
return out
# 卷积核梯度
def conv2d_grad(x, grad_out):
dW = np.zeros_like(W_conv)
for i in range(3):
for j in range(3):
region = x[i:i+3, j:j+3]
dW += grad_out[i, j] * region
return dW
# 训练循环
pbar = tqdm.tqdm(total=20000, desc="Training")
while loss > 1e-6 and step < 20000:
conv = conv2d(X, W_conv) + b_conv
relu = np.maximum(conv, 0)
flat = relu.reshape(-1, 1)
y_pred = W_fc.T @ flat + b_fc
loss = (y_pred - y_true)**2
loss_list.append(loss[0][0])
dL_dy = 2 * (y_pred - y_true)
dL_dW_fc = flat * dL_dy
dL_db_fc = dL_dy
dL_dflat = W_fc * dL_dy
dL_drelu = dL_dflat.reshape(3, 3)
dL_dconv = dL_drelu * (conv > 0)
dL_dW_conv = conv2d_grad(X, dL_dconv)
dL_db_conv = np.sum(dL_dconv)
W_fc -= lr * dL_dW_fc
b_fc -= lr * dL_db_fc
W_conv -= lr * dL_dW_conv
b_conv -= lr * dL_db_conv
pbar.set_postfix({"loss": f"{loss[0][0]:.6e}"})
pbar.update(1)
step += 1
pbar.close()
# 输出结果
print("\nTraining finished.")
print("Final loss:", loss[0][0])
print("Conv kernel:\n", W_conv)
print("FC weights:", W_fc.ravel())
print("Prediction:", y_pred[0][0])
print("Label:", y_true[0][0])
# Loss 曲线图
plt.figure(figsize=(6, 4))
plt.plot(loss_list)
plt.xlabel("Step")
plt.ylabel("Loss (MSE)")
plt.title("CNN Training Loss Curve")
plt.grid(True)
plt.tight_layout()
plt.show()
# CNN 可视化(卷积前后)
conv_final = conv2d(X, W_conv)
relu_final = np.maximum(conv_final, 0)
plt.figure(figsize=(10, 4))
plt.subplot(1, 3, 1)
plt.imshow(X, cmap="gray")
plt.title("Input Image")
plt.axis("off")
plt.subplot(1, 3, 2)
plt.imshow(conv_final, cmap="gray")
plt.title("Conv Output")
plt.axis("off")
plt.subplot(1, 3, 3)
plt.imshow(relu_final, cmap="gray")
plt.title("ReLU Output")
plt.axis("off")
plt.tight_layout()
plt.show()

小结#

这就是一个最简单的 CNN 实现。包含了卷积、ReLU、全连接层和反向传播。

核心思想就是:

  1. 卷积层:用卷积核在图像上滑动,提取局部特征
  2. 激活函数:用 ReLU 引入非线性
  3. 全连接层:把特征映射到最终输出
  4. 反向传播:计算梯度,更新参数

虽然这个模型简单到只用了 29 步就收敛,但它包含了 CNN 的所有核心组件。后面那些复杂的架构(ResNet、VGG、Inception),本质上也是在这个基础上堆叠更多的层、加入更多的技巧。

[!quote] “The journey of a thousand miles begins with a single step.”
— Lao Tzu

上一篇:ML1 - 用梯度下降把线性回归从零撸出来 下一篇:ML3-手写神经网络的四大基础组件


Works Cited

Goodfellow, Ian, et al. Deep Learning. MIT Press, 2016.

LeCun, Yann, et al. “Gradient-Based Learning Applied to Document Recognition.” Proceedings of the IEEE, vol. 86, no. 11, 1998, pp. 2278-2324, https://doi.org/10.1109/5.726791.

从零手搓一个卷积神经网络
https://blog.lishuyu.top/posts/ml2-从零手搓一个卷积神经网络/
作者
猫猫魔女
发布于
2025-11-20
许可协议
CC BY-NC-SA 4.0