070、NumPy 实战:用 NumPy 从零实现一个简单的神经网络前向传播

发布时间:2026/6/28 18:30:48
070、NumPy 实战:用 NumPy 从零实现一个简单的神经网络前向传播 070、NumPy 实战用 NumPy 从零实现一个简单的神经网络前向传播上周帮一个刚入行的同事排查模型推理结果全为NaN的问题他用的就是自己手写的神经网络。我一看代码激活函数里直接用了np.exp输入值稍微大一点就溢出梯度直接炸了。这种坑我踩过不止一次今天干脆把用NumPy实现前向传播的完整过程拆开揉碎了讲清楚顺便把那些容易翻车的地方都标出来。为什么非要用NumPy手写前向传播别误会生产环境没人会手写神经网络PyTorch、TensorFlow它不香吗但如果你连最基础的矩阵乘法、激活函数、损失计算都搞不清楚调框架的时候遇到bug就只能靠玄学。我见过太多人把nn.Linear当成黑盒结果维度对不上就报错连matmul和dot的区别都说不清楚。手写一次前向传播你至少能搞明白三件事权重矩阵的维度为什么是(输入维度, 输出维度)、偏置项怎么广播、激活函数到底在干什么。这些基础打牢了后面学框架就是降维打击。先搭一个最简单的两层网络假设我们要做一个二分类任务输入层4个特征隐藏层3个神经元输出层1个神经元sigmoid输出概率。别急着写代码先把维度算清楚。输入数据X的形状是(m, 4)m是样本数。隐藏层权重W1形状(4, 3)别写成(3, 4)这里踩过坑——矩阵乘法要求(m, 4) (4, 3)才能得到(m, 3)。隐藏层偏置b1形状(1, 3)但NumPy广播机制允许你直接用(3,)不过为了可读性我建议保持二维。输出层权重W2形状(3, 1)。输出层偏置b2形状(1, 1)。importnumpyasnp# 模拟数据别用真实数据调试先用随机数验证维度np.random.seed(42)# 固定种子方便复现Xnp.random.randn(100,4)# 100个样本4个特征# 初始化参数别全零初始化否则梯度对称网络学不动W1np.random.randn(4,3)*0.01# 小随机数防止梯度消失/爆炸b1np.zeros((1,3))# 偏置可以零初始化W2np.random.randn(3,1)*0.01b2np.zeros((1,1))这里有个细节权重为什么要乘0.01如果直接用标准正态分布初始化对于深层网络激活值会集中在sigmoid的饱和区梯度趋近于零。虽然我们只有两层但养成好习惯。前向传播一步一步来别想一口吃成胖子前向传播分三步线性变换、激活函数、输出层处理。每一步都打印形状这是调试的黄金法则。# 第一步隐藏层线性变换Z1np.dot(X,W1)b1# 或者用 X W1效果一样# 这里别写成 np.dot(W1, X)维度对不上会报错print(fZ1 shape:{Z1.shape})# 应该是 (100, 3)# 第二步隐藏层激活用ReLU别用sigmoid梯度消失问题少A1np.maximum(0,Z1)# ReLU简单粗暴print(fA1 shape:{A1.shape})# (100, 3)# 第三步输出层线性变换Z2np.dot(A1,W2)b2print(fZ2 shape:{Z2.shape})# (100, 1)# 第四步输出层激活二分类用sigmoidA21/(1np.exp(-Z2))# 这里就是同事翻车的地方print(fA2 shape:{A2.shape})# (100, 1)看到那个np.exp(-Z2)了吗如果Z2里有很大的负数np.exp会返回接近0的值没问题。但如果Z2里有很大的正数比如100np.exp(-100)接近0分母接近1也没问题。真正危险的是Z2里有很大的负数绝对值比如-100np.exp(100)直接溢出变成inf然后1/(1inf)0再然后反向传播梯度全变0。正确的做法要么对Z2做裁剪要么用scipy.special.expit它内部做了数值稳定处理。但既然我们手写就加个裁剪# 安全版本的sigmoiddefsigmoid(x):# 别直接np.exp先裁剪xnp.clip(x,-500,500)# 经验值-500到500足够return1/(1np.exp(-x))A2sigmoid(Z2)损失函数二分类交叉熵有了预测值A2和真实标签y就可以算损失了。注意y的形状要和A2一致都是(m, 1)。ynp.random.randint(0,2,(100,1))# 模拟标签0或1# 交叉熵损失别直接写 log(A2)A2可能为0# 加一个极小值防止log(0)epsilon1e-8loss-np.mean(y*np.log(A2epsilon)(1-y)*np.log(1-A2epsilon))print(fLoss:{loss:.4f})这里epsilon加得很有讲究。如果某个样本预测值A2恰好等于0log(0)就是-inf整个loss就崩了。虽然理论上sigmoid输出不会精确等于0但浮点数精度问题下可能发生。我见过有人不加这个结果训练到一半loss突然变成nan排查了半天。完整的前向传播函数把上面这些封装成一个函数方便复用。注意输入参数的类型检查别传错了维度。defforward_propagation(X,params): 两层神经网络前向传播 params: dict包含 W1, b1, W2, b2 返回: cache包含各层中间值反向传播要用 W1,b1,W2,b2params[W1],params[b1],params[W2],params[b2]# 隐藏层Z1np.dot(X,W1)b1 A1np.maximum(0,Z1)# ReLU# 输出层Z2np.dot(A1,W2)b2# 数值稳定的sigmoidZ2_clippednp.clip(Z2,-500,500)A21/(1np.exp(-Z2_clipped))# 缓存中间值反向传播要用cache{Z1:Z1,A1:A1,Z2:Z2,A2:A2}returnA2,cache# 测试一下params{W1:W1,b1:b1,W2:W2,b2:b2}A2,cacheforward_propagation(X,params)print(f预测值范围: [{A2.min():.4f},{A2.max():.4f}])调试技巧用极小数据验证别一上来就用100个样本先用2个样本、2个特征手动算一遍结果和代码输出对比。我每次写新网络都这么干。# 极小测试用例X_testnp.array([[0.5,0.2],[0.1,0.8]])# 2样本2特征W1_testnp.array([[0.1,0.2],[0.3,0.4]])# 2输入-2隐藏b1_testnp.array([[0.01,0.02]])W2_testnp.array([[0.5],[0.6]])# 2隐藏-1输出b2_testnp.array([[0.1]])params_test{W1:W1_test,b1:b1_test,W2:W2_test,b2:b2_test}A2_test,_forward_propagation(X_test,params_test)print(f手动验证结果:{A2_test})# 你可以拿计算器算一下第一个样本# Z1 [0.5*0.10.2*0.30.01, 0.5*0.20.2*0.40.02] [0.110.01, 0.10.080.02] [0.12, 0.20]# A1 ReLU([0.12, 0.20]) [0.12, 0.20]# Z2 0.12*0.5 0.20*0.6 0.1 0.060.120.1 0.28# A2 sigmoid(0.28) ≈ 0.5695个人经验建议维度检查是第一生产力。每次矩阵运算后打印shape养成肌肉记忆。我见过最蠢的bug是W1和W2维度写反了结果网络变成了“输入层直接到输出层”隐藏层根本没起作用。初始化不是小事。全零初始化在对称性破坏上会出问题但更大的坑是初始化太大。对于ReLU网络推荐用He初始化np.random.randn(shape) * np.sqrt(2/n_input)。对于sigmoid/tanh用Xavier初始化。别偷懒这直接决定你的网络能不能收敛。数值稳定性是硬道理。np.exp、np.log、除法这三个操作是NaN和inf的重灾区。任何时候都要考虑边界情况加epsilon、做clip别嫌麻烦。不要迷信框架。框架帮你隐藏了细节但也隐藏了bug。当你用PyTorch写nn.Sequential的时候心里要清楚每一层在做什么矩阵运算。我面试人的时候经常问“sigmoid的梯度公式是什么”能答上来的人写代码基本不会出维度错误。写代码前先画图。把网络结构画出来标上每一层的输入输出维度再动手写。这比任何调试技巧都管用。最后说一句手写前向传播不是为了造轮子是为了理解轮子。等你把反向传播也手写一遍再去看PyTorch的autograd你会觉得它简直是上帝赐予的礼物。