1. 从一张28x28的灰度图说起:为什么全连接网络依然是入门首选
很多人第一次接触深度学习,都是从手写数字识别这个任务开始的。数据集是现成的,图片小、类别少、背景干净,看起来没什么挑战性。但真正动手写过一遍的人会知道,这个任务里藏着神经网络最核心的几个问题:输入怎么组织、权重怎么初始化、前向传播的矩阵维度怎么对齐、损失函数怎么选、梯度为什么会消失。把这些问题在手写数字上搞明白了,后面换任何数据集,无非是换一层壳。
全连接前向神经网络,说白了就是一堆矩阵乘法加上非线性激活函数堆叠起来的结构。它没有卷积的局部感受野,也没有循环网络的时间依赖,每一层的每个神经元都和上一层的所有神经元相连。这种"全连接"的特性让它在处理小尺寸图像时反而有优势——28x28的灰度图展平后是784维向量,直接送进网络就行,不需要考虑通道、步长、填充这些卷积特有的概念。
我见过不少初学者一上来就搞ResNet、Transformer,结果连反向传播的链式法则都说不清楚。手写数字这个任务的好处在于,它的计算量小到可以在CPU上跑完,你可以把每一层的输出形状、每一个梯度的来源都打印出来看。这种"透明感"是其他任务给不了的。
这篇文章面向的是刚入门神经网络、想亲手写一遍前向传播和反向传播的人。我会从数据预处理开始,一步步搭一个全连接网络,把每个设计决策背后的理由讲清楚,最后分享几个我在实际调试中踩过的坑。代码用Python和NumPy实现,不依赖任何深度学习框架,目的是让你看清每一个矩阵乘法的形状变化。
2. 数据准备:784维向量是怎么来的,以及为什么要归一化
2.1 手写数字数据集的原始形态与展平操作
手写数字数据集通常以两种形式存在:一种是原始的IDX格式文件,包含训练集60000张、测试集10000张28x28的灰度图;另一种是已经被各种库封装好的数组形式。不管哪种形式,每张图片的像素值范围都是0到255的整数,0代表纯黑,255代表纯白。
全连接网络的第一层是一个线性层,它接收的输入必须是一个一维向量。所以第一步就是把28x28的二维数组展平成一个784维的向量。这个操作在NumPy里就是reshape(-1, 784),在PyTorch里可以用view或者flatten。展平的顺序很重要——必须按行优先,也就是先第一行的28个像素,再第二行的28个像素,以此类推。如果顺序搞错了,比如按列优先展平,网络依然能训练,但相当于给图像做了一次固定的置换,虽然理论上全连接网络可以学到这种置换,但会浪费大量的参数容量。
展平之后的数据形状是(样本数, 784)。假设批量大小是64,那么一个批次的输入就是(64, 784)的矩阵。这个矩阵会送进第一层线性变换,假设第一层有128个神经元,那么权重矩阵的形状就是(784, 128),偏置是(128,)。矩阵乘法的结果(64, 784) @ (784, 128) = (64, 128),这就是第一层的输出。
2.2 归一化的必要性:为什么除以255还不够
原始像素值是0到255的整数,直接送进网络会出问题。第一层线性变换的计算是z = x @ W + b,如果x的数值范围是0到255,那么z的数值范围会非常大,经过激活函数后容易饱和。比如Sigmoid函数在输入大于5或小于-5时梯度几乎为零,ReLU虽然不会饱和,但过大的输入会导致梯度爆炸。
最常见的做法是除以255,把像素值缩放到0到1之间。但我在实际训练中发现,仅仅除以255还不够稳定。更好的做法是做标准化,也就是减去均值再除以标准差。对于手写数字数据集,全局均值大约是0.1307,标准差大约是0.3081。标准化后的数据分布更接近标准正态分布,梯度下降的收敛速度会明显加快。
这里有一个细节:均值和标准差必须从训练集计算,然后应用到测试集。如果用整个数据集计算,就造成了数据泄露。虽然在这个任务里影响不大,但养成好习惯很重要。
import numpy as np def normalize_images(images, mean=0.1307, std=0.3081): images = images.astype(np.float32) / 255.0 images = (images - mean) / std return images2.3 标签的两种处理方式:整数编码与独热编码
标签是0到9的整数。全连接网络的最后一层通常有10个输出,对应10个类别。损失函数用交叉熵的话,标签可以是整数形式,框架会自动做独热编码;但如果自己用NumPy实现,就需要手动把整数标签转成独热向量。
独热编码的好处是直观,每个类别对应一个长度为10的向量,只有对应位置是1,其余是0。交叉熵损失的计算就是-sum(y_true * log(y_pred)),其中y_pred是经过Softmax后的概率分布。如果标签是整数,交叉熵可以简化为-log(y_pred[true_class]),计算量更小。
我在自己实现的时候倾向于用整数标签,因为反向传播时梯度计算更简单:Softmax的输出减去独热标签,得到的就是梯度。如果用独热标签,需要先做一次减法;如果用整数标签,可以直接在对应位置减1。
3. 网络结构设计:层数、激活函数与初始化策略的取舍
3.1 隐藏层宽度与深度的权衡
全连接网络的结构设计没有固定公式,但有一些经验规律。对于手写数字这种简单任务,一层隐藏层通常就够了。隐藏层神经元数量从64到512都有人用,我试过128和256,差别不大。层数方面,两层隐藏层比一层隐藏层在训练集上表现更好,但测试集上容易过拟合。
我最终选择的结构是:784 -> 256 -> 128 -> 10。第一层隐藏层256个神经元,第二层128个,输出层10个。这个结构参数量大约是784256 + 256128 + 128*10 = 200704 + 32768 + 1280 = 234752个参数。对于60000张训练图片来说,参数量适中,既不会欠拟合,也不会严重过拟合。
为什么第一层比第二层宽?因为第一层直接接收原始像素,需要更多的神经元来捕捉不同的局部模式。第二层是在第一层的特征基础上做组合,抽象程度更高,需要的神经元可以少一些。这个"金字塔"式的结构在很多任务里都适用。
3.2 ReLU vs Sigmoid vs Tanh:激活函数的选择逻辑
激活函数的作用是引入非线性。如果没有激活函数,多层线性变换堆叠起来等价于一层线性变换,网络的表达能力就退化了。
Sigmoid函数1/(1+exp(-x))的输出范围是0到1,看起来很适合做概率,但它有两个致命问题:一是梯度消失,当输入很大或很小时梯度接近零,反向传播时浅层几乎收不到梯度;二是输出不是零中心的,导致梯度更新方向呈锯齿状,收敛慢。
Tanh函数是零中心的,输出范围-1到1,比Sigmoid好一些,但梯度消失问题依然存在。
ReLU函数max(0, x)在正区间梯度恒为1,彻底解决了梯度消失问题,计算也简单。缺点是负区间梯度为零,可能导致"神经元死亡"。但在手写数字任务里,这个问题不严重,因为输入经过标准化后,大约一半的像素是负值,ReLU会自然筛选。
我最终选择ReLU作为隐藏层激活函数,输出层用Softmax。Softmax的作用是把10个实数输出转成概率分布,所有输出之和为1,方便计算交叉熵。
3.3 权重初始化:为什么不能全零,Xavier和He怎么选
权重初始化是很多人忽略的一步。如果所有权重初始化为零,那么同一层的所有神经元输出完全相同,反向传播时梯度也相同,相当于只有一个神经元在工作。这叫"对称性破缺"问题。
如果权重初始化得太大,前向传播时激活值会爆炸;太小则激活值趋近于零,梯度也会消失。所以初始化的目标是让每一层的输出方差保持一致。
Xavier初始化适用于Sigmoid和Tanh激活函数,它让权重服从均值为0、方差为2/(fan_in + fan_out)的分布。He初始化适用于ReLU,方差是2/fan_in。因为ReLU把一半的神经元置零了,所以需要更大的方差来补偿。
def he_initialize(fan_in, fan_out): std = np.sqrt(2.0 / fan_in) return np.random.randn(fan_in, fan_out) * std偏置通常初始化为零,因为权重已经打破了对称性,偏置不需要再引入随机性。
4. 前向传播与反向传播:手写一遍比调库理解深十倍
4.1 前向传播的矩阵维度追踪
前向传播的过程就是一层一层做线性变换加激活。以我的结构为例,输入是(batch_size, 784),第一层权重W1是(784, 256),偏置b1是(256,)。第一层线性输出z1 = x @ W1 + b1,形状(batch_size, 256)。经过ReLU后得到a1,形状不变。
第二层权重W2是(256, 128),偏置b2是(128,)。z2 = a1 @ W2 + b2,形状(batch_size, 128)。经过ReLU得到a2。
第三层权重W3是(128, 10),偏置b3是(10,)。z3 = a2 @ W3 + b3,形状(batch_size, 10)。经过Softmax得到概率分布y_pred。
这里有一个常见的坑:偏置的加法。x @ W1的形状是(batch_size, 256),b1的形状是(256,)。NumPy的广播机制会自动把b1扩展成(batch_size, 256),所以直接相加没问题。但如果用其他语言实现,需要手动扩展。
4.2 交叉熵损失与Softmax的梯度推导
交叉熵损失的定义是L = -sum(y_true * log(y_pred)),其中y_pred = softmax(z3)。Softmax的函数是softmax(z)_i = exp(z_i) / sum(exp(z_j))。
反向传播时,我们需要计算dL/dz3。这个梯度有一个非常优雅的形式:dL/dz3 = y_pred - y_true。也就是说,Softmax的输出减去独热标签,就是损失对最后一层线性输出的梯度。这个结论的推导需要用到链式法则和Softmax的导数,但记住这个结果就够了。
这个梯度形式的好处是数值稳定。如果直接计算dL/dy_pred再乘以dy_pred/dz3,会遇到除零和溢出问题。而y_pred - y_true天然在-1到1之间,非常安全。
4.3 反向传播的链式法则与参数更新
得到dL/dz3后,反向传播就是一层一层往前推。对于第三层:
dL/dW3 = a2.T @ dL/dz3,形状(128, 10)dL/db3 = sum(dL/dz3, axis=0),形状(10,)dL/da2 = dL/dz3 @ W3.T,形状(batch_size, 128)
然后经过ReLU的反向:dL/dz2 = dL/da2 * (z2 > 0),因为ReLU的导数是1(正区间)或0(负区间)。
继续往前:
dL/dW2 = a1.T @ dL/dz2dL/db2 = sum(dL/dz2, axis=0)dL/da1 = dL/dz2 @ W2.TdL/dz1 = dL/da1 * (z1 > 0)dL/dW1 = x.T @ dL/dz1dL/db1 = sum(dL/dz1, axis=0)
参数更新用梯度下降:W = W - learning_rate * dL/dW。学习率通常从0.01或0.001开始试,太大容易震荡,太小收敛慢。
def backward(x, y_true, cache, params): z1, a1, z2, a2, z3, y_pred = cache W1, b1, W2, b2, W3, b3 = params dz3 = (y_pred - y_true) / x.shape[0] dW3 = a2.T @ dz3 db3 = np.sum(dz3, axis=0) da2 = dz3 @ W3.T dz2 = da2 * (z2 > 0) dW2 = a1.T @ dz2 db2 = np.sum(dz2, axis=0) da1 = dz2 @ W2.T dz1 = da1 * (z1 > 0) dW1 = x.T @ dz1 db1 = np.sum(dz1, axis=0) return dW1, db1, dW2, db2, dW3, db3注意dz3除以了x.shape[0],这是为了对批量大小做平均,避免梯度随批量大小变化。
5. 训练过程中的坑:从损失不下降到过拟合的完整排查
5.1 损失值不下降的三种常见原因
第一次跑训练时,损失值可能一直停在2.3左右不动。2.3是-log(0.1),意味着网络对所有类别的预测概率都是0.1,相当于随机猜测。这种情况通常有三个原因:
第一,学习率太大。梯度更新步长过大,参数在最优解附近震荡,甚至发散。可以试着把学习率降到0.001或0.0001。
第二,权重初始化有问题。如果权重全部初始化为零,或者方差太小,前向传播的输出会趋近于零,Softmax输出均匀分布。检查初始化的方差是否合理。
第三,数据预处理有问题。如果忘记归一化,像素值在0到255之间,第一层线性输出会非常大,ReLU后大部分神经元饱和,梯度为零。检查输入数据的均值和标准差。
我遇到过一次损失不下降的情况,排查了半天发现是标签和图片没有对齐——图片做了shuffle但标签没有跟着shuffle。这种低级错误在实际项目中很常见,建议在数据加载后打印前几个样本的标签和图片,肉眼确认一下。
5.2 训练集准确率上升但测试集准确率停滞
训练几个epoch后,训练集准确率能到99%以上,但测试集准确率停在97%左右不再上升。这是典型的过拟合。全连接网络参数量大,60000张图片相对有限,过拟合是正常的。
缓解过拟合的方法有几种。最简单的是加Dropout,在隐藏层后面随机置零一部分神经元。Dropout率通常设0.2到0.5,我试过0.3效果不错。另一种是L2正则化,在损失函数里加上权重平方和的惩罚项,系数通常设0.0001到0.001。
还有一个容易被忽略的点:早停。如果测试集准确率连续几个epoch不上升,就停止训练。继续训练只会让训练集准确率更高,测试集反而可能下降。
def dropout(a, rate, training=True): if not training: return a mask = np.random.rand(*a.shape) > rate return a * mask / (1 - rate)注意训练时除以(1 - rate),保持期望不变;测试时不加Dropout。
5.3 批量大小与学习率的联动关系
批量大小影响梯度的噪声程度。批量太小(比如8),梯度噪声大,训练不稳定但可能跳出局部最优;批量太大(比如512),梯度估计准确,但容易陷入 sharp minimum,泛化性能差。
经验法则是:批量大小翻倍,学习率也翻倍。因为批量增大后梯度的方差减小,可以用更大的步长。我通常从批量64、学习率0.01开始试,如果损失震荡就减小学习率,如果收敛太慢就增大。
还有一个技巧是学习率衰减。每训练几个epoch,把学习率乘以0.9或0.5。这样前期大步快走,后期小步微调,最终准确率通常比固定学习率高0.5到1个百分点。
6. 实测结果与几个值得记录的细节
6.1 最终模型在测试集上的表现
用上述结构训练20个epoch,批量大小64,学习率0.01,每5个epoch衰减为原来的0.5,测试集准确率能到98.2%左右。如果加上Dropout和L2正则化,能到98.5%。这个成绩在全连接网络里算不错了,卷积网络通常能到99%以上,但那是另一个话题。
训练时间方面,在普通笔记本的CPU上,一个epoch大约需要15到20秒,20个epoch不到7分钟。如果用GPU,速度会快很多,但这个任务本身计算量小,CPU完全够用。
6.2 权重可视化:第一层学到了什么
把第一层权重W1的每一列重塑成28x28的图像,可以看到网络学到了什么。有些列看起来像数字的笔画边缘,有些像特定方向的纹理。这说明即使没有卷积的局部连接,全连接网络也能从数据中自动学到有意义的特征。
这个可视化还有一个用途:检查权重是否正常更新。如果某些列的图像几乎全黑或全白,说明对应的神经元可能死亡了,需要调整初始化或学习率。
6.3 一个容易被忽略的细节:随机种子
神经网络的训练结果对随机种子敏感。不同的权重初始化、不同的数据shuffle顺序,最终准确率可能相差0.3到0.5个百分点。如果要复现实验结果,必须在代码开头固定随机种子。
np.random.seed(42)但要注意,固定种子后,如果换了硬件或库版本,结果可能依然不同。所以论文里的"可复现"通常指的是统计意义上的可复现,而不是逐位一致。
7. 从全连接网络到更复杂结构的自然延伸
手写数字这个任务跑通之后,下一步通常是换更复杂的数据集,比如Fashion-MNIST或者CIFAR-10。这时候全连接网络的局限性就暴露了:参数量随图像尺寸平方增长,而且没有利用图像的局部相关性。28x28的灰度图展平后是784维,如果是224x224的彩色图,展平后是150528维,第一层权重矩阵会大到无法训练。
卷积网络通过局部连接和权重共享解决了这个问题。但理解卷积网络的前提,是理解全连接网络的前向传播和反向传播。卷积层本质上就是稀疏连接的全连接层,加上权重共享的约束。如果把卷积核的每个位置都看作一个独立的权重,那它就是一个全连接层,只是大部分权重被强制为零。
所以我的建议是:不要跳过全连接网络直接学卷积。花一个周末把全连接网络手写一遍,把梯度推导清楚,把训练过程中的坑踩一遍。后面再学卷积、循环、注意力机制,会发现底层逻辑都是相通的。
我在实际教学中发现,那些手写过反向传播的学生,后面调参时更有方向感。他们知道损失不下降该检查什么,知道梯度消失该换什么激活函数,知道过拟合该加什么正则化。这些直觉不是看几篇教程就能获得的,必须亲手写一遍、跑一遍、错一遍。
最后分享一个调试技巧:在训练循环里定期打印每一层权重的均值和标准差。正常情况下,权重的均值应该在零附近,标准差在0.01到0.1之间。如果某一层的标准差突然变得很大或很小,说明那一层出了问题,可能是学习率太大或者初始化不合适。这个习惯帮我省了很多排查时间。