从像素矩阵到高维向量:纯NumPy手写前馈神经网络
图像到底是怎么"喂"给神经网络、又是怎么一步步变成那个高维度向量的?这个疑问我在后台收到了无数次。很多读者学完了前面的基础理论,矩阵乘法也懂了,反向传播公式也能推导,但一到图像分类实战就卡住:一张图片明明是二维的像素点,凭什么能被模型识别成"猫"或者"狗"?中间那几百个浮点数到底代表什么?
这一篇,我们就围绕这个问题做一次完整的实战拆解——从前馈神经网络的视角,把图像从像素矩阵到特征向量再到分类输出的全过程走一遍。代码继续沿用系列一贯的原则:只用纯 Python + NumPy,不依赖任何深度学习框架。这样虽然实现细节多一些,但每一步的矩阵长什么样、梯度从哪里来,都清清楚楚。
先说明这篇的定位。如果你已经跟着这个系列读到了第十二篇,说明反向传播、激活函数、损失函数这些底子已经打过了。今天更像一次"串糖葫芦"式的综合实战,把那些零散知识点串成一条完整链路:数据预处理 -> 网络结构设计 -> 前向传播 -> 反向传播 -> 训练调参 -> 问题排查。如果你是刚点开这篇的新读者,也不用慌,我会尽量把每一步的前因后果解释清楚,你只需要带着一个疑问往下读:一张 28x28 的灰度图,究竟是如何变成一个 10 维向量的?
1. 图像的"本来面目":像素矩阵是唯一的入口
1.1 一张图在计算机里就是一堆数字
先把最底层的概念钉死:计算机不认识"图像",只认识"矩阵"。拿常见的 MNIST 手写数字数据集举例,每一张图都是 28 像素宽、28 像素高、单通道的灰度图。读进内存后,它就是一个形状为 (28, 28) 的二维数组,每个元素是 0 到 255 之间的整数,0 代表纯黑,255 代表纯白,中间值是深浅不同的灰。
彩色图像则是三维数组,形状是 (高, 宽, 通道数),通道数一般为 3,对应红、绿、蓝三个颜色通道。大家看热词里频繁出现"图像进入神经网络到输出高维度向量",这个入口就是矩阵。把矩阵想象成一块由数字构成的"马赛克拼图",神经网络能做的,只是在这块拼图上做各种线性变换和非线性变换,从来没真正"看见"过图像。
有个容易混淆的点是通道顺序。OpenCV 读图默认是 BGR 顺序,也就是蓝色通道在前,而日常处理时我们通常希望是 RGB。如果直接把 OpenCV 读出来的图喂给按 RGB 顺序训练的网络,结果会非常诡异——颜色信息全乱了。所以读图之后第一件事往往是cv2.cvtColor(img, cv2.COLOR_BGR2RGB)做通道转换。这一点在热词里反复出现"python下载cv2"、"python安装cv2",很多初学者装完库就直接读图跑模型,最后发现准确率上不去,排查半天其实是通道顺序错了。
1.2 预处理不是走形式,是在给梯度"修路"
原始像素值 0~255 直接送进网络,在数学上不是不行,但会产生一个实际痛点:数值范围太大,梯度计算容易不稳定。我们看权重更新公式:w = w - lr * grad。如果输入特征是几百量级的数字,那么矩阵乘法的结果可能也是几百量级,经过激活函数之后,梯度要么大得离谱导致发散,要么因为某些激活函数饱和区梯度极小导致学不动。
常见的处理手段是归一化。把像素值从 0~255 映射到 0~1 区间,直接除以 255.0 就行;更进一步,还可以做标准化,让数据均值为 0、标准差为 1。说白了,归一化是给梯度下降"修了一条平缓的路":各个维度的数值量级一致,权重更新时不会因为某个特征天然特别大而被带偏。
我在实战中还习惯加上一步:把二维矩阵拉平成向量。对于 28x28 的灰度图,这一步就是img.reshape(1, 784),或者一次性处理大批量数据时用X.reshape(-1, 784)。很多初学者在这里犯迷糊,为什么好好的二维矩阵要拉成一维?因为标准的全连接网络(前馈神经网络)输入层要求的是一个一维特征向量,每个像素就是一个特征维度。这个"拉平"操作等价于把图像的二维空间结构暂时丢弃,这对全连接网络是必要的牺牲。
注意:预处理里有一类操作要特别小心——归一化统计量要单独用训练集计算,不能把验证集或测试集的数据混进来一起算。否则相当于测试信息提前"泄露"给了模型,验证结果会偏乐观,上了真实环境准确率就露馅。
2. 网络骨架怎么搭:从输入层的 784 到输出层的 10
2.1 隐藏层宽度怎么定
输入维度定为 784 是确定的——28x28 拉平之后就是这个数。输出维度定为 10 也是确定的——MNIST 有 0 到 9 共 10 个类别。卡在设计表中间的隐藏层:到底几层?每层多少个神经元?
这里没有绝对正确的标准答案,但有一条工程经验可以分享:先把隐藏层设置为单层、宽度取 64 或 128,作为基线版本跑通全流程。等确认正向传播、反向传播、损失计算都准确无误,再逐步加深加宽,对比效果。一上来就搭一个 [784, 512, 256, 128, 10] 的五层大网,万一效果不好,你根本分不清是代码 bug 还是模型容量问题。我在初学阶段踩过这个坑,五层网络跑出来的损失是 nan,排查了整整半天,最后发现只是初始化不当导致的梯度爆炸。
对于手写数字这类简单任务,单隐藏层 128 个神经元其实已经够用了。模型容量这个词听起来玄乎,打个比方:神经元数量就像做菜时手头的调料种类。调料太少,很多味道调不出来(欠拟合);调料太多,容易照着一道特定的菜死记硬背(过拟合),换个环境就抓瞎。
2.2 权重矩阵的维度推导,手把手算一遍
这一节是整个实战里最值得盯着的部分。我们用 W1、b1 表示输入层到隐藏层的参数,W2、b2 表示隐藏层到输出层的参数。输入样本 x 的形状是 (1, 784),矩阵乘法规则要求 W1 的形状必须是 (784, 128)。算出来的隐藏层输出形状就是 (1, 128),即:
z1 = x @ W1 + b1 # (1, 784) @ (784, 128) -> (1, 128)这里的@是 NumPy 的矩阵乘法运算符,要求左边矩阵的列数等于右边矩阵的行数。W1 的 784 就是这么来的。b1 的形状是 (1, 128),广播机制会自动把偏置加到每一行上。
隐藏层到输出层同理:z2 的输出维度必须是 10,所以 W2 的形状是 (128, 10),b2 是 (1, 10)。整体参数加起来算一下:
- W1: 784 x 128 = 100352 个参数
- b1: 128 个参数
- W2: 128 x 10 = 1280 个参数
- b2: 10 个参数
总计约 101770 个可学习参数。这个数字意味着,在没有引入任何卷积结构的前提下,模型需要通过训练从几万张图片中学习约十万个权重。对一台普通笔记本电脑来说,CPU 跑几十个 epoch 也就是几分钟的量级,完全可接受。
参数初始化的方法我多说一句。常见做法是用小随机数,np.random.randn生成标准正态分布后除以某个衰减系数。为什么要除?因为如果权重初始值太大,经过多层矩阵乘法后信号会指数级放大,很容易导致激活函数进入饱和区,梯度趋近于零。工程上常用的是配合激活函数来选择初始化的量级,比如配合 ReLU 时可以用np.sqrt(2.0 / n_input)作为标准差。别小看这一步,初始化对训练收敛速度的影响比大多数人想象的大得多。
2.3 输出层的"高维向量"到底意味着什么
很多人卡住的点来了:输出层那 10 个数字是什么意思?我先给结论:网络输出的是一个形状为 (1, 10) 的向量,里面每个元素是"这张图属于对应类别"的得分(logit)。经过 softmax 之后,这些得分被转换成 10 个和为 1 的概率值。请注意,在训练过程中,我们不会直接把 softmax 后的概率拿去和标签做比较,而是直接对 logits 做交叉熵损失计算。这么设计是因为 softmax 内部有 exp 运算,如果先算概率再算 log 损失,两步合并时数值上会有精度损失,而且梯度表达式也能化简成非常优雅的形式,这个等讲到反向传播再展开。
向量里的第 0 个元素是"是 0 的概率",第 1 个元素是"是 1 的概率",以此类推。训练时标签做 one-hot 编码,比如真实标签是 3,那就构造向量 [0, 0, 0, 1, 0, 0, 0, 0, 0, 0]。模型要做的,就是不断调整那约十万个权重,让网络对真实类别给出的得分尽可能高、对其他类别的得分尽可能低。这个"高维向量"的维度等于类别数——所以如果你做的是 1000 类图像分类,最后一层就输出 1000 维向量;如果做的是人脸识别里的特征提取,输出层可能是一个 128 维的向量,这个向量就是"人脸特征"的数学表示。
我在之前写人脸识别相关的热词搜索里也看到类似的疑惑:"人脸识别图像进入神经网络到输出高维度向量的过程"。其实核心链路和我们这里完全一样:网络前几层提取像素特征,中间层组合成抽象特征,最后输出一个固定维度的向量。区别只在于训练目标——分类任务让向量不同维度的数值对应不同类别的得分,人脸特征提取任务则是让同一个人的两张脸对应的两个向量在空间里距离更近。
3. 前向传播与反向传播:两步核心代码
3.1 从输入到输出的一次完整"旅行"
前向传播在纯 NumPy 里真的很直接,代码长相如下:
def forward(x, W1, b1, W2, b2): z1 = x @ W1 + b1 a1 = np.maximum(0, z1) # ReLU 激活 z2 = a1 @ W2 + b2 return z1, a1, z2这里有个小技巧:训练时我们不仅需要最终的输出 z2,还需要中间量 z1 和 a1 缓存下来,因为反向传播要用到它们。很多初学者写 forward 只返回最终结果,到写 backward 时才发现缺了中间量,又得重算一遍。缓存中间结果这个习惯,是从业者都会遵守的,省下的不只是重复计算的算力,更是排查 bug 时清晰的路线图。
激活函数选 ReLU 而不是 sigmoid,原因在于梯度特性。sigmoid 在两端的导数非常接近 0,误差信号经过多层传播后会衰减到几乎消失,这是"梯度消失"问题的根源。ReLU 在正区间导数恒为 1,负区间导数恒为 0,不存在饱和区,信号可以比较顺畅地反向传播。但代价是负区间的神经元一旦输出为 0,它的梯度就永远是 0,对应的权重再也无法更新——这是"神经元死亡"问题。后面排查章节会再聊它。
3.2 反向传播:交叉熵的梯度到底是怎么简化的
反向传播是整个系列里劝退率最高的部分,但今天这套网络结构其实算起来很规整,就两条链路。先说损失函数:我们用交叉熵。定义式是:
L = -log(softmax(z2)[0, y_true])意思是:取 softmax 之后真实类别对应的那个概率值,然后取负对数。真实类别概率越接近 1,损失越接近 0;概率越小,损失越大。
对整个输出向量做梯度推导,最终结果非常优美:softmax 加上交叉熵的组合,对 z2 的梯度恰好就是"模型预测的概率向量减去 one-hot 标签向量"。
da2 = probs - y_onehot # 形状 (1, 10)为什么能化简成这么干净?因为 softmax 求导后会出现"自己对自己"和"自己对别人"的分支,而交叉熵里的 log 恰好把这些分支全部约掉了。这就是我当时说的"两步合并"的威力。推导过程我在系列前面某一篇详细展开过,这里不再重复,但每个入门者都值得自己完整推一遍,推通了之后神经网络对你再无秘密。
有了 da2,后面就是纯粹的链式法则机械操作:
dW2 = a1.T @ da2 # (128, 1) @ (1, 10) -> (128, 10) db2 = np.sum(da2, axis=0, keepdims=True) da1 = da2 @ W2.T # (1, 10) @ (10, 128) -> (1, 128) dz1 = da1 * (z1 > 0) # ReLU 的导数,正区间为 1,负区间为 0 dW1 = x.T @ dz1 # (784, 1) @ (1, 128) -> (784, 128) db1 = np.sum(dz1, axis=0, keepdims=True)注意 db 为什么用np.sum而不是直接等于某个矩阵。因为偏置 b 是广播到每个样本上的,梯度回传时需要把所有样本的贡献加起来。这是初学者最容易写错的地方——觉得 db 应该是某个矩阵,结果形状对不上。
3.3 训练循环和参数更新
参数更新的代码反而最简单:
W1 -= lr * dW1 b1 -= lr * db1 W2 -= lr * dW2 b2 -= lr * db2但"训练循环"这个整体设计里有个容易被忽略的选择:每次更新梯度时,用一个样本算,还是用一批样本算?这叫 batch size 的选择。用全量样本算出平均梯度再更新,数学上最稳定,但每轮更新都要跑完整数据集,速度慢;用单个样本更新,速度快但梯度噪声大,损失曲线会像心电图一样乱跳;折中的 mini-batch 是主流做法,比如一次取 64 个样本算平均梯度。批次越小梯度噪声越大,越小越容易跳。批次越大,梯度估计越准确,但内存压力和计算开销同步上升。
对于 MNIST 这种只有 784 个输入维度的简单任务,全量更新其实也能接受。不过真实项目中数据量动辄几十万甚至上亿,mini-batch 就是必选项了。我在这个系列里一直建议,理解原理时先跑全量,等逻辑验证通过再改成 mini-batch 也不迟。
4. 收尾工作:初始化、学习率与训练节奏
4.1 初始化和学习率是"最难调试的超参数"
神经网络训练里有一句老话:模型结构决定上限,优化过程决定能不能达到上限。而优化过程里,初始化方式、学习率、批次大小这三个因素几乎决定了训练能否正常收敛。我见过不少同行花大量时间调网络结构,最后发现瓶颈只是学习率设置不当。
学习率太大,损失函数会在最小值附近来回震荡甚至直接发散,表现就是 loss 变成 nan 或者准确率在 10% 左右疯狂抖动。学习率太小,每一步更新都小心翼翼,训练几百个 epoch 还在原地踏步,损失函数下降得肉眼看不见。一个粗暴有效的经验:初次跑一个任务时,把学习率定在 0.001 量级,观察前几个 epoch 的损失曲线。如果损失在下降,哪怕不快,也说明方向对了;如果发散,减到 0.0001 再试;如果几乎不动,加大到 0.01。
在这个网络里,我常用的初始学习率是 0.01 配合全量梯度更新,或者 0.001 配合 mini-batch。如果实现正确,通常 5 个 epoch 左右训练集准确率就能到 95% 以上。如果 20 个 epoch 后训练集还不到 90%,那大概率是代码哪里写错了,别急着加网络宽度。
4.2 训练过程中的"心电图"怎么盯
训练过程不只是盯着最终准确率看,中间过程信息量巨大。我习惯每隔几个 epoch 打印一次训练损失和验证集准确率。损失下降说明模型还在学,准确率跟着上升说明学对了方向;但如果损失的下降速度远快于准确率的上升速度,就要警惕过拟合了——模型可能在死记训练样本的模式,而不是泛化出通用规律。
另一个直观经验是看损失曲线是否平滑。如果曲线剧烈震荡且始终不收敛,优先调低学习率,而不是怀疑数据有问题。如果损失曲线呈现阶梯状下降,每过若干轮突然跌一块,多半是学习率调度器的效果,这属于正常现象。如果不做任何调度,曲线一般是一条平滑下降的弧线,后期逐渐走平。
5. 踩坑实录:六个最容易翻车的细节
5.1 维度不匹配的时候怎么"破案"
初学阶段最频繁的报错就是维度对不上。我的调试思路是:在 forward 和 backward 的每一行中间都打印张量的 shape,从输入一直看到输出,确认每一步矩阵乘法都符合预期。这个手段虽然笨,但非常可靠。
工具方面,推荐用 assert 断言初步排查,例如assert a1.shape == (1, 128),跑不过去立刻定位。等代码稳定后再删掉也不迟。
5.2 死亡 ReLU:隐藏层一半神经元"罢工"
ReLU 的负区间梯度为 0,这意味着一旦某个神经元在训练中落入负输出区间,它的权重梯度永远为 0,权重就再也无法被更新——这个神经元"死"了。表现是训练损失卡住不动,但网络结构表面看起来完全正常。
排查方法是打印隐藏层激活值的分布。如果数据集中某个 epoch 之后,隐藏层输出大量为 0,说明死亡 ReLU 发生了。诱因通常是学习率太大,权重一步更新过猛,把一大批神经元直接推到负区间。解法是降低学习率,或者换用带泄漏的 ReLU(Leaky ReLU)。
5.3 损失变成 NaN 的三种原因
NaN 是训练幺蛾子里最常见的一号选手。它的常见原因有三个:学习率太大导致数值溢出;softmax 内部 exp 运算遇到极大 logits 产生数值上溢;标签错误导致 one-hot 向量错位。解决思路第一个直接降学习率;第二个在计算 softmax 时给 logits 先减去最大值,这是数值稳定性的经典技巧,不影响最终概率结果;第三个检查数据加载代码。
5.4 数据顺序不洗牌,模型学到的是"排列规律"
如果训练时每次都按固定顺序喂数据,模型可能不是在学图像特征,而是在记忆"第几号样本属于第几类"的排列规律。这样训练集准确率可能虚高,验证集一换顺序立刻打回原形。正确做法是每次 epoch 开始前把训练数据索引打乱,mini-batch 再从打乱后的数据里抽。
5.5 归一化参数只用训练集的,别让测试集"偷看答案"
这个前面提过,但实际工程里真的很常见。我见过一个项目,开发者在预处理时对所有数据一起做归一化,结果离线测试准确率 98%,上线后跌到 80% 出头,最后排查发现就是归一化统计量混入了测试数据。生产环境里的图像分布和训练集有差异,这件事本身已经够头疼了,千万别在预处理环节再给自己挖坑。
5.6 全连接网络的"高维向量"迷信
最后说一个观念层面的坑。全连接网络对图像分类确实能跑通,但图像的空间结构(比如相邻像素之间的相关性)在"拉平"操作中被抹掉了。这也是为什么复杂一点的图像任务大家会转向卷积神经网络——卷积天然保留空间邻接信息。这次我们从前馈网络角度把流程跑通,明白"图像 -> 矩阵 -> 特征向量 -> 分类输出"的全貌,对后续理解卷积神经网络会有极大帮助。卷积核本质上做的事情和全连接一样,都是在做"特征提取 + 线性变换",只是加上了"只看局部"的约束。
6. 实测参数参考与效果预期
以 MNIST 为例,在普通家用电脑上,用纯 NumPy 实现单隐藏层 128 神经元网络,配合 mini-batch 64、学习率 0.01、交叉熵损失、ReLU 激活,训练 20 个 epoch 左右,验证集准确率大约在 94%~96% 区间。作为对照,加上卷积结构后能轻松超过 99%,但那是后面系列的话题。前馈网络能达到的这个数字,对"理解全链路"这个目标来说,合格了。
如果想快速验证自己的代码是否有 bug,有一个省事的妙招:用极小数据集做训练。比如只取 100 张图训练 100 个 epoch,如果模型能把损失降到接近 0,说明前向传播和反向传播是自洽的;如果损失居高不下,代码大概率写错了。这个小技巧几乎可以推广到所有神经网络项目,谁用谁知道。
另外提一句"从零开始"这四个字:有读者问我为什么不用 PyTorch / TensorFlow,答案是只有亲手写过一遍权重矩阵的维度推导、亲自在反向传播推导上翻过车,后面用框架时遇到报错才知道框架在帮自己做什么、哪一步出了什么问题。框架封装的越高级,底层原理越容易被忽略,但恰恰是这些被封装掉的东西,决定了你能把模型调到多好。
我个人的体会是:当你能闭上眼睛在纸上画出 784 -> 128 -> 10 这个简单网络的全量前向传播和反向传播计算图时,再看任何深度学习框架的文档、任何模型的代码实现,都会觉得异常亲切。因为不管网络层数加到几十层、结构换成卷积还是 Transformer,底层那几个核心操作——线性变换、非线性激活、损失函数、梯度回传,一直都没有变过。
这一篇到这里,算是把"图像输入到高维向量输出"的全过程从零串了一遍。如果你正卡在某个维度报错或者损失不降的问题上,不妨把代码里每个张量的 shape 全部打印出来,一行行对着上面第 2.2 节的计算过程检查,问题通常会自己现身。下一篇我们在这个基础上加入卷积结构,到时候你会看到,图像的空间信息是怎么被重新利用起来的。