ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

从零手写前馈神经网络:NumPy实现前向传播与反向传播

从零手写前馈神经网络:NumPy实现前向传播与反向传播

1. 从零搭网络这件事,为什么值得反复折腾

很多人学神经网络,卡在第一步:教程里全是model.fit(),两行代码跑完,准确率还挺高,但脑子里一片空白。真到了要改结构、调梯度、排查loss不下降的时候,完全无从下手。我自己最早也是这样,跟着教程跑通了MNIST,觉得自己会了,结果换一个自定义数据集,连输入维度都对不上。

这个系列做到第六篇,我想聊的不是某个新模型,而是把"从零开始"这四个字真正落地。所谓从零,不是不用框架,而是你得亲手把前向传播、反向传播、参数更新这三件事用NumPy写一遍,哪怕只写一个三层的前馈神经网络。写完之后再回头看PyTorch,你会发现那些nn.Linear、loss.backward()到底在干什么,心里有底了。

这篇内容适合两类人:一类是刚学完Python基础、想入门神经网络但被各种框架绕晕的新手;另一类是用过框架、但总觉得理解不透彻、想补底层功课的开发者。我会从最基础的数据准备讲起,一路写到反向传播的手动实现、训练调参的坑,以及怎么把这个从零写的网络用到真实场景里。全程用NumPy,不依赖任何深度学习框架,代码可以直接跑。

关键词里出现了前馈神经网络、BP神经网络、卷积神经网络、LSTM、GNN这些概念,我不会全都展开,但会在合适的位置说清楚它们和从零实现的关系——毕竟你手写一遍前馈网络之后,理解CNN的卷积核、LSTM的门控机制,会顺畅很多。

2. 手写前馈网络之前,先把数据管道捋顺

2.1 为什么数据准备比模型结构更值得花时间

我见过太多人,模型结构抄得飞快,结果训练半天loss不动,最后发现是标签没对齐,或者输入没归一化。从零实现的时候,数据管道是你自己控制的,这反而是个好事——你能清楚知道每一步数据长什么样。

以最经典的手写数字分类为例,假设你从某个公开数据集拿到的是28x28的灰度图。第一步是把它展平成784维向量,因为前馈网络的输入层是固定长度的向量。这里有个容易忽略的点:展平的时候是按行优先还是列优先?NumPy的reshape(-1)默认是C顺序,也就是按行展平,只要你训练和推理用同一套逻辑,就不会出问题。但如果你训练用NumPy,推理用别的库,就得确认展平顺序一致。

归一化是第二个关键。像素值原本是0到255,直接喂给网络,梯度会非常大,训练极不稳定。标准做法是除以255,把值压到0到1之间。更进一步可以做标准化,减去均值除以标准差,但对于图像这种有界数据,除以255通常就够了。

import numpy as np def load_data(): # 假设X_train是(N, 28, 28),y_train是(N,) X_train = X_train.reshape(X_train.shape[0], -1) / 255.0 X_test = X_test.reshape(X_test.shape[0], -1) / 255.0 # 标签转one-hot num_classes = 10 y_train_onehot = np.eye(num_classes)[y_train] y_test_onehot = np.eye(num_classes)[y_test] return X_train, y_train_onehot, X_test, y_test_onehot

标签为什么要转one-hot?因为输出层用softmax,配合交叉熵损失,one-hot形式让损失计算和梯度推导都更干净。如果你用稀疏标签,就得在损失函数里做索引,容易出错。

2.2 训练集、验证集、测试集的划分逻辑

从零实现的时候,很多人只分训练和测试,忽略了验证集。验证集的作用是调超参数——学习率、隐藏层大小、迭代轮数。如果你用测试集调参,那测试集就不再是"没见过"的数据,评估结果会偏乐观。

我的习惯是训练集占70%,验证集15%,测试集15%。划分之前一定要打乱顺序,否则如果数据本身有序(比如按类别排列),划分出来的集合分布会严重偏斜。打乱用np.random.permutation生成索引,然后按索引取数据。

def split_data(X, y, val_ratio=0.15, test_ratio=0.15): N = X.shape[0] indices = np.random.permutation(N) val_size = int(N * val_ratio) test_size = int(N * test_ratio) val_idx = indices[:val_size] test_idx = indices[val_size:val_size + test_size] train_idx = indices[val_size + test_size:] return (X[train_idx], y[train_idx], X[val_idx], y[val_idx], X[test_idx], y[test_idx])

这里有个实操心得:划分完之后,打印一下每个集合的类别分布,确认没有某个类别在验证集里一个样本都没有。小数据集上这种情况很常见,一旦出现,验证指标会剧烈波动,你会误以为是模型问题。

2.3 输入维度和批次大小的选择

输入维度由数据决定,784就是784,没什么可调的。但批次大小(batch size)是个需要权衡的参数。批次太小,比如1,就是随机梯度下降,梯度噪声大,训练震荡;批次太大,比如整个训练集,梯度稳定但内存吃紧,而且容易陷入尖锐极小值,泛化反而差。

从零实现的时候,我建议批次大小取32或64。这个量级既能利用NumPy的矩阵运算效率,又不会让内存爆掉。实现上,每个epoch把训练集打乱,然后按批次切分:

def iterate_minibatches(X, y, batch_size, shuffle=True): N = X.shape[0] indices = np.random.permutation(N) if shuffle else np.arange(N) for start in range(0, N, batch_size): end = min(start + batch_size, N) batch_idx = indices[start:end] yield X[batch_idx], y[batch_idx]

注意最后一个批次可能不足batch_size,这没关系,矩阵运算照样能跑,只是那个批次的样本数少一些。但如果你在批归一化里用了固定形状的统计量,就得小心处理。

3. 前向传播:把矩阵乘法串成一条线

3.1 单层线性变换的维度推导

前馈网络的核心就是线性变换加非线性激活。假设输入是784维,隐藏层1有256个神经元,隐藏层2有128个,输出层10个。那么第一层的权重矩阵W1形状是(784, 256),偏置b1形状是(256,)。为什么是这个形状?因为输入X形状是(batch_size, 784),要做矩阵乘法X @ W1,W1的行数必须等于784,列数等于下一层的神经元数256。

这里有个新手常犯的错误:把权重形状搞反,写成(256, 784),然后做W1 @ X,结果维度对不上。记住一个口诀:权重矩阵的形状是(上一层维度, 下一层维度),前向传播是输入在左,权重在右。

def linear_forward(X, W, b): return X @ W + b

偏置b的加法利用了NumPy的广播机制。X @ W的形状是(batch_size, 256),b的形状是(256,),广播之后每一行都加上b,等价于给每个样本的每个隐藏单元加同一个偏置。这是正确的,因为偏置是层级别的参数,不随样本变化。

3.2 激活函数的选择与实现细节

没有激活函数,多层线性变换堆叠起来还是线性变换,网络再深也等价于一层。所以激活函数是非线性的来源。常用的有Sigmoid、Tanh、ReLU。

Sigmoid把输出压到0到1,但有个致命问题:当输入很大或很小时,梯度接近0,反向传播时梯度会逐层衰减,这就是梯度消失。Tanh类似,输出范围是-1到1,梯度消失问题稍好但依然存在。ReLU(Rectified Linear Unit)在正区间梯度恒为1,极大缓解了梯度消失,而且计算极快,就是max(0, x)。

从零实现时,我推荐隐藏层用ReLU,输出层用Softmax。ReLU的导数很简单:输入大于0时为1,否则为0。实现时要注意,反向传播需要用到前向传播时的输入,所以要么存下来,要么重新算。

def relu_forward(X): return np.maximum(0, X) def relu_backward(dout, X): # X是前向传播时的输入 return dout * (X > 0)

Softmax稍微复杂一点,它把一组实数转成概率分布。公式是exp(x_i) / sum(exp(x_j))。直接实现有个数值稳定性问题:如果x很大,exp(x)会溢出。标准做法是先减去最大值:

def softmax_forward(X): # X形状(batch_size, num_classes) shifted = X - np.max(X, axis=1, keepdims=True) exp_X = np.exp(shifted) return exp_X / np.sum(exp_X, axis=1, keepdims=True)

减去最大值不改变softmax的结果,因为分子分母同乘了一个常数,但能避免溢出。这个技巧在实际工程里是标配,自己写的时候千万别省。

3.3 把层串起来:前向传播的完整流程

有了线性层和激活层,前向传播就是把它们按顺序调用,同时把中间结果缓存下来供反向传播使用。一个两层隐藏的网络:

def forward(X, params): W1, b1, W2, b2, W3, b3 = params z1 = X @ W1 + b1 a1 = relu_forward(z1) z2 = a1 @ W2 + b2 a2 = relu_forward(z2) z3 = a2 @ W3 + b3 probs = softmax_forward(z3) cache = (X, z1, a1, z2, a2, z3) return probs, cache

缓存的内容包括每一层的输入和线性变换后的结果。为什么z1和a1都要存?因为ReLU的反向传播需要z1(判断正负),而下一层的反向传播需要a1。存这些中间结果会占内存,但这是用空间换时间的典型做法。如果内存实在紧张,可以只存必要的一部分,反向传播时重算,但那样代码会复杂很多。

4. 反向传播:梯度到底是怎么流回去的

4.1 从损失函数开始往回推

反向传播的本质是链式法则。损失函数用交叉熵,配合softmax,有一个非常优雅的结论:softmax输出对logits的梯度,乘以交叉熵的梯度,简化后就是probs - y_onehot。这个结论我建议你亲手推一遍,推完之后你会对为什么softmax和交叉熵是绝配理解深刻。

def cross_entropy_loss(probs, y_onehot): N = probs.shape[0] # 防止log(0) log_probs = -np.log(probs[np.arange(N), np.argmax(y_onehot, axis=1)] + 1e-12) return np.sum(log_probs) / N def softmax_cross_entropy_backward(probs, y_onehot): N = probs.shape[0] return (probs - y_onehot) / N

注意这里除以了N,因为损失是batch内平均,梯度也要对应平均。如果你忘了除N,梯度会大N倍,学习率就得相应调小,容易乱。

4.2 逐层反传:线性层和ReLU层的梯度计算

从输出层往回,先经过softmax交叉熵,得到dlogits。然后经过最后一个线性层z3 = a2 @ W3 + b3。线性层的反向传播有三件事:算对输入a2的梯度、算对W3的梯度、算对b3的梯度。

对输入的梯度是dlogits @ W3.T,形状回到(batch_size, 128)。对W3的梯度是a2.T @ dlogits,形状是(128, 10)。对b3的梯度是np.sum(dlogits, axis=0),因为b3对每个样本都加了同一个值,梯度要把batch维度求和。

def linear_backward(dout, X, W): dX = dout @ W.T dW = X.T @ dout db = np.sum(dout, axis=0) return dX, dW, db

ReLU的反向传播更简单,就是把上游梯度乘以一个掩码,输入大于0的位置保留,否则置零:

def relu_backward(dout, z): return dout * (z > 0)

把这两者交替应用,就能从输出层一路传回输入层。每一层的梯度都依赖前向传播时缓存的中间值,这也是为什么前向传播必须存cache。

4.3 梯度检查:手写反向传播的保命手段

手写反向传播最容易出错的地方是维度对不上或者公式写错。梯度检查(gradient check)是验证正确性的黄金标准。原理是数值梯度近似解析梯度:对某个参数加上微小扰动epsilon,计算损失变化,除以2*epsilon,得到数值梯度,然后和反向传播算出的解析梯度比较。

def gradient_check(params, X, y_onehot, epsilon=1e-7): # 只检查第一个权重矩阵的若干元素 W1 = params[0] for i in range(min(5, W1.shape[0])): for j in range(min(5, W1.shape[1])): old = W1[i, j] W1[i, j] = old + epsilon loss_plus, _ = forward(X, params) loss_plus = cross_entropy_loss(loss_plus, y_onehot) W1[i, j] = old - epsilon loss_minus, _ = forward(X, params) loss_minus = cross_entropy_loss(loss_minus, y_onehot) W1[i, j] = old num_grad = (loss_plus - loss_minus) / (2 * epsilon) # 和解析梯度比较 # ...

相对误差小于1e-6通常说明实现正确。如果误差在1e-4量级,可能是某个地方有细微错误;如果误差很大,基本可以确定公式写错了。梯度检查很慢,只在调试时用,训练时关掉。

5. 训练循环里的那些坑与调参经验

5.1 参数初始化:全零初始化为什么不行

如果所有权重初始化为0,那么同一层的所有神经元在前向传播时输出相同,反向传播时梯度也相同,更新后还是相同,相当于一层只有一个神经元在起作用。这叫对称性问题。

正确的做法是随机初始化。常用的有Xavier初始化和He初始化。Xavier适合Sigmoid和Tanh,方差是2 / (fan_in + fan_out);He初始化适合ReLU,方差是2 / fan_in。fan_in是输入维度,fan_out是输出维度。

def init_params(layer_dims): params = [] for i in range(len(layer_dims) - 1): fan_in = layer_dims[i] fan_out = layer_dims[i + 1] W = np.random.randn(fan_in, fan_out) * np.sqrt(2.0 / fan_in) b = np.zeros((fan_out,)) params.append(W) params.append(b) return params

用np.random.randn生成标准正态分布,再乘以标准差。注意不要用np.random.random,那是均匀分布,范围是0到1,均值0.5,会导致初始输出偏移。

5.2 学习率的选取与动态调整

学习率是训练中最难调的参数。太大,loss震荡甚至发散;太小,收敛慢,可能卡在局部极小值。从零实现时,我建议先用一个中等值,比如0.01,跑几个epoch看loss曲线。如果loss上下跳动,就调小;如果loss下降极慢,就调大。

更聪明的做法是学习率衰减:一开始用大学习率快速下降,后期用小学习率精细调整。简单实现是每过几个epoch乘以一个衰减因子,比如0.9:

def train(X_train, y_train, X_val, y_val, layer_dims, epochs, batch_size, lr): params = init_params(layer_dims) for epoch in range(epochs): current_lr = lr * (0.9 ** (epoch // 5)) for X_batch, y_batch in iterate_minibatches(X_train, y_train, batch_size): probs, cache = forward(X_batch, params) grads = backward(probs, y_batch, cache, params) for i in range(0, len(params), 2): params[i] -= current_lr * grads[i] params[i + 1] -= current_lr * grads[i + 1] # 每个epoch结束打印验证集准确率 val_probs, _ = forward(X_val, params) val_acc = np.mean(np.argmax(val_probs, axis=1) == np.argmax(y_val, axis=1)) print(f"Epoch {epoch}, val_acc: {val_acc:.4f}") return params

这里每5个epoch衰减一次,衰减因子0.9。实际用的时候可以根据验证集准确率来调,如果验证准确率不再提升,就衰减。

5.3 过拟合的识别与应对

训练集准确率一直涨,验证集准确率涨到某个点后开始跌,这就是过拟合。从零实现时,应对过拟合最直接的手段是L2正则化和Dropout。

L2正则化是在损失里加上权重平方和的一半乘以lambda,梯度里对应加上lambda乘以权重。这样权重会被拉向0,模型复杂度降低。Dropout是在训练时随机把一部分神经元的输出置零,相当于每次训练一个子网络,推理时用全部神经元但输出要乘以保留概率。

def dropout_forward(X, drop_prob): mask = np.random.rand(*X.shape) > drop_prob return X * mask / (1 - drop_prob), mask def dropout_backward(dout, mask, drop_prob): return dout * mask / (1 - drop_prob)

注意推理时不做dropout,但训练时除以了(1-drop_prob),所以推理时直接前向即可,不需要额外缩放。这个细节叫inverted dropout,是现在的主流做法。

6. 从手写网络到真实场景的迁移思路

6.1 换数据集时最容易忽略的维度问题

手写网络跑通MNIST之后,换一个数据集,最常见的报错是维度不匹配。比如你换成一个特征维度是20的表格数据,输入层就得改成20,而不是784。这个改动看似简单,但如果你把层维度写死在代码里,就得改好几处。

我的做法是把层维度定义成一个列表,比如layer_dims = [784, 256, 128, 10],初始化和前向传播都从这个列表推导。换数据集时只改这个列表,其他代码不动。这样代码的复用性会好很多。

另外,表格数据通常需要标准化,而不是简单除以255。标准化是减去均值除以标准差,让每个特征的分布接近标准正态。这一步对神经网络训练至关重要,尤其是特征量纲差异大的时候。

6.2 多分类之外:回归和二分类怎么改

手写网络默认是softmax多分类。如果任务是二分类,输出层改成1个神经元加Sigmoid,损失用二元交叉熵。如果任务是回归,输出层不加激活,损失用均方误差。

def sigmoid_forward(X): return 1 / (1 + np.exp(-np.clip(X, -500, 500))) def binary_cross_entropy(probs, y): N = probs.shape[0] return -np.sum(y * np.log(probs + 1e-12) + (1 - y) * np.log(1 - probs + 1e-12)) / N

Sigmoid里加了clip,防止exp溢出。这些改动都不大,但体现了从零实现的好处:你知道每一处为什么这么改,而不是在框架里找参数。

6.3 手写实现和框架的边界在哪里

手写前馈网络的价值在于理解原理,但真到生产环境,还是得用框架。框架有自动微分、GPU加速、成熟的优化器,手写代码在这些方面没法比。但手写一遍之后,你用框架时会更清楚每个API背后的含义。

比如你看到optimizer.step(),知道它是在做参数更新;看到loss.backward(),知道它是在做反向传播;看到model.train()和model.eval(),知道是Dropout和BatchNorm的行为切换。这种理解能让你在排查问题时更有方向。

关键词里提到的卷积神经网络、LSTM、GNN,本质上都是在前馈网络的基础上加了结构先验。CNN加了局部连接和权重共享,LSTM加了门控和记忆单元,GNN加了图结构上的消息传递。你手写前馈网络时练的矩阵运算、梯度推导、维度对齐,在这些更复杂的结构里同样适用。

7. 一些调试和性能上的实操心得

7.1 loss不下降时的排查顺序

loss不下降,先别急着改模型。按这个顺序排查:第一,检查数据有没有问题,标签对不对齐,输入有没有归一化;第二,检查学习率,太大太小都会导致不下降;第三,检查初始化,全零初始化肯定不行;第四,用梯度检查验证反向传播;第五,减小网络规模,先用一个极小的网络看能不能过拟合几个样本。

过拟合几个样本是个很实用的技巧:取10个样本,让网络去拟合,如果连这10个都拟合不了,说明网络或训练过程有根本性问题。正常情况下一两百个epoch应该能达到接近100%的训练准确率。

7.2 NumPy矩阵运算的性能优化点

从零实现时,性能瓶颈通常在矩阵乘法。NumPy的@运算符底层是BLAS库,已经很快了,但你可以通过减少不必要的拷贝来进一步优化。比如X @ W + b会先算矩阵乘法再算加法,产生一个临时数组。如果内存紧张,可以用np.add(X @ W, b, out=result)来复用内存。

另一个点是数据类型。默认float64精度高但慢,改成float32能快将近一倍,而且对神经网络训练精度影响很小。初始化时用np.float32,数据加载时也转成float32。

X = X.astype(np.float32) W = np.random.randn(fan_in, fan_out).astype(np.float32) * np.sqrt(2.0 / fan_in)

7.3 把训练过程可视化的简单方法

从零实现没有TensorBoard,但你可以用matplotlib画loss曲线和准确率曲线。每个epoch记录训练loss和验证loss,训练结束后画出来。如果训练loss下降但验证loss上升,就是过拟合;如果两者都不降,就是欠拟合或训练有问题。

import matplotlib.pyplot as plt plt.plot(train_losses, label='train') plt.plot(val_losses, label='val') plt.xlabel('Epoch') plt.ylabel('Loss') plt.legend() plt.show()

这个图比任何数字都直观。我习惯每训练一个模型就画一次,看一眼曲线形状就知道训练状态。

8. 写在最后:从零实现之后的路怎么走

手写一遍前馈网络,最大的收获不是代码本身,而是那种"我知道每一步在干什么"的踏实感。之后你再去看CNN的卷积操作,会发现它不过是把全连接换成了局部连接加权重共享;看LSTM的门控,会发现它是在前馈的基础上加了几个Sigmoid和Tanh来控制信息流。

如果你想把这份代码继续扩展,我建议从两个方向入手:一是加更多层和更多激活函数,感受深度对表达能力的影响;二是把优化器从朴素梯度下降换成动量或Adam,感受优化算法对收敛速度的影响。这两个方向都不需要改太多代码,但能让你对神经网络的理解再上一个台阶。

代码我放在这里,你可以直接拿去跑。遇到维度报错的时候,别急着搜答案,先自己把每个矩阵的形状写下来,一步步推。这个推导过程本身,比跑通代码更有价值。

返回列表