(后注:这个顺序应该是错了的,但是问题是我跟着老师讲的顺序搓的,so …)
写完线性回归之后,老板又给我扔了个新活:手写一个卷积神经网络(CNN)。
虽然知道这玩意儿在深度学习框架里就是几行代码的事,但手写一遍能让人真正理解卷积核是怎么滑动的、梯度是怎么回传的。
累是累了点,但至少能在面试时拍着胸脯说:“我真的懂 CNN。“
什么是卷积神经网络
卷积神经网络(CNN)是专门用来处理图像数据的神经网络。它的核心操作是卷积,用一个小的卷积核在图像上滑动,提取局部特征。
一个最简单的 CNN 通常包括:
- 卷积层:用卷积核提取特征
- 激活函数:引入非线性(比如 ReLU)
- 全连接层:把特征映射到最终输出
今天我们要实现的就是这样一个最简单的 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 的机制,而不是训练一个能用的分类器。
初始化参数
我们需要两组参数:
- 卷积层参数:一个 3×3 的卷积核
W_conv和一个偏置b_conv - 全连接层参数:权重
W_fc和偏置b_fc
W_conv = np.random.randn(3, 3) * 0.1b_conv = 0.0
fc_in_dim = 3 * 3 # 卷积后的输出是 3×3W_fc = np.random.randn(fc_in_dim, 1) * 0.1b_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这个函数做的事情很简单:
- 从输入图像中提取一个 3×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×3dL_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_fcb_fc -= lr * dL_db_fcW_conv -= lr * dL_dW_convb_conv -= lr * dL_db_conv这和线性回归完全一样。把参数往梯度的反方向挪一点。
完整训练循环
把上面的所有步骤串起来:
import tqdm
lr = 0.01step = 0loss = 999loss_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-07Conv 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.9992448339593532Label: 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()这三张图分别是:
- 输入图像:原始的 5×5 图像(斜十字形状)
- 卷积输出:经过卷积核后的 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)本质上就是在这个基础上:
- 堆叠更多层:多个卷积层 + 池化层 + 全连接层
- 增加卷积核数量:第一层 32 个,第二层 64 个,第三层 128 个……
- 添加批归一化:加速训练,稳定梯度
- 使用更好的优化器:Adam、RMSprop 代替普通的 SGD
- 数据增强:旋转、翻转、裁剪,增加数据多样性
但核心思想没变:用卷积提取特征,用梯度下降优化参数。
完整代码
把所有代码整合在一起:
import numpy as npimport tqdmimport 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.1b_conv = 0.0
fc_in_dim = 3 * 3W_fc = np.random.randn(fc_in_dim, 1) * 0.1b_fc = 0.0
lr = 0.01step = 0loss = 999loss_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、全连接层和反向传播。
核心思想就是:
- 卷积层:用卷积核在图像上滑动,提取局部特征
- 激活函数:用 ReLU 引入非线性
- 全连接层:把特征映射到最终输出
- 反向传播:计算梯度,更新参数
虽然这个模型简单到只用了 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.