
简介这份 Python 代码包面向深度学习入门者与希望理解神经网络底层计算的学习者以简单全连接网络为主题用 numpy 从零实现多层感知器覆盖权重初始化、sigmoid 激活、前向传播、均方误差损失、反向传播、训练迭代与预测输出可作为进入框架学习前的基础练习。压缩包共 7 个文件全部为 py 脚本整体约 4KB体量轻便文件按网络定义、公共数据、算法实现、训练入口与预测调用等职责拆分便于按流程阅读、逐段调试和复现实验。已有 4217 人学习说明该示例在入门练手场景中具有一定参考价值。读者可据此跑通一个小规模全连接网络观察损失变化与预测结果并对照代码梳理梯度计算和参数更新细节代码结构紧凑也适合在本地快速运行并逐步改造网络层数、学习率等参数为后续使用 TensorFlow、Keras 或 PyTorch 等框架打下基础。1. 先别急着调库一个两层全连接网络能让你看清什么上周有人拿着一份两千行的 CSV 过来说用现成的分类器跑出 0.93 的准确率但被追问「梯度到底怎么传回去的」就卡住了。这类场景特别适合自己手写一遍全连接神经网络——不是为了替代成熟框架而是让反向传播从黑匣子变成四行看得懂的矩阵乘法。这里拆的这份 Python 实现是纯 NumPy 的两层全连接网络前向、反向、softmax 交叉熵、mini-batch 训练、SGD 与 Adam 全部自己写一百多行不依赖任何深度学习框架装好 NumPy 就能跑。它解决的不是「训更大的模型」而是「在几万行结构化数据上把每一步都看清楚」输入是二维特征矩阵输出是类别概率隐层宽度、激活函数、优化器、正则强度全是你手能改到的参数。适合三类人刚学完链式法则想落地验证的、面试前要把反向传播推导讲顺的、手里有几万行表格数据想快速试特征又不想引入重型依赖的从业者。2. 前向与反向把两层网络的矩阵形状和四个梯度一次写对手写网络翻车最多的不是数学是形状。很多人推导能推一写代码就报operands could not be broadcast together然后开始瞎转置转着转着梯度就错了loss 还能勉强下降于是错误被掩盖到最后一刻。这一章的做法是先把所有张量的形状钉死写在注释里再往里填公式最后用一段几十行的代码把前向和反向跑通。2.1 先把形状钉死X、W、b 的维度约定约定只有一条样本永远在行特征永远在列。输入X形状是(N, D)N 是样本数D 是特征维数输出logits形状是(N, C)C 是类别数。权重矩阵一律用(fan_in, fan_out)偏置一律用一维(fan_out,)靠 NumPy 的广播自动加到每一行上。张量形状含义备注X(N, D)一个 batch 的输入已标准化W1(D, H)输入层到隐层fan_inD, fan_outHb1(H,)隐层偏置一维靠广播Z1 / A1(N, H)隐层线性输出 / 激活后同形状W2(H, C)隐层到输出层fan_inH, fan_outCb2(C,)输出层偏置一维logits / probs(N, C)未归一化得分 / 概率行和为 1偏置为什么不写成(H, 1)因为一旦写成列向量X W1 b1会广播成(N, H)看着没问题但反向时db1 dZ1.sum(axis0)的形状是(H,)和(H,1)的b1相减又广播成(H,H)参数直接变成矩阵后面全乱。这种错误不会立刻报错只会让 loss 卡在某个值附近抖动属于典型的血泪经验。2.2 前向传播ReLU 与数值稳定的 softmaximport numpy as np def relu(z): return np.maximum(z, 0.0) def softmax(logits): # 减去每行最大值防止 exp 溢出对最终概率没有影响 z logits - logits.max(axis1, keepdimsTrue) e np.exp(z) return e / e.sum(axis1, keepdimsTrue) def forward(params, X): W1, b1, W2, b2 params[W1], params[b1], params[W2], params[b2] Z1 X W1 b1 # (N, D) (D, H) - (N, H) A1 relu(Z1) # 逐元素形状不变 logits A1 W2 b2 # (N, H) (H, C) - (N, C) probs softmax(logits) # 每行归一化 cache (X, Z1, A1, probs) return probs, cache逐行说清楚。X W1 b1里b1是(H,)NumPy 会把它广播到(N, H)的每一行等价于给每个样本加同一个偏置向量这正是我们要的。relu用np.maximum(z, 0.0)而不是z * (z 0)原因是后者在 z 恰好为 0 时会得到-0.0虽然数值上无差别但某些调试打印会让人困惑而且np.maximum更快一点。softmax里的减最大值那一步不能省。假设 logits 某一维是 800np.exp(800)直接溢出成infinf / inf得到nanloss 立刻变 nan然后你会怀疑人生地改学习率——其实跟学习率毫无关系。减去每行最大值后最大的那一项变成exp(0)1其余都是小于 1 的数绝不会溢出而数学上softmax(z) softmax(z - max(z))是恒等的。cache把反向要用的中间量全存下来这是手写网络的标准做法。省内存可以只存Z1和probsA1 relu(Z1)和X都能重算但初学阶段别省多存几个中间量调试时能直接在断点里看。还有一个小参数值得提前说隐层宽度H。单隐层做表格数据分类H 取 32 到 256 之间通常够用特征维度 D 很大比如几百上千时从 64 起步先跑通再加宽。加宽不是万能药H 从 64 加到 512 而验证集不涨问题八成在特征或正则上不在容量上。2.3 反向传播从 softmax 交叉熵直接拿到 dlogits先看损失。多分类用交叉熵对一个 batch 取平均def cross_entropy(probs, y): N probs.shape[0] # y 是 0..C-1 的整数标签clip 防止 log(0) return -np.log(np.clip(probs[np.arange(N), y], 1e-12, 1.0)).mean()probs[np.arange(N), y]是花式索引取出每个样本对应正确类别的概率形状(N,)。np.clip是后悔药如果某个样本被预测成概率接近 0 的正确类log(0)会得到-inf整个 loss 变 nan。加了下限之后最多损失掉一点精度但训练不会崩。反向传播时有一个非常省事的结论softmax 加交叉熵对 logits 的梯度就是probs - onehot(y)再除以 N。不需要先求 softmax 的雅可比矩阵再和交叉熵的梯度相乘那一步的化简结果就是它。所以def backward(params, cache, y, l20.0): X, Z1, A1, probs cache N X.shape[0] # softmax 交叉熵的联合梯度已经包含 1/N 的平均 dlogits probs.copy() dlogits[np.arange(N), y] - 1.0 dlogits / N # (N, C) dW2 A1.T dlogits # (H, N) (N, C) - (H, C) db2 dlogits.sum(axis0) # (C,) dA1 dlogits params[W2].T # (N, C) (C, H) - (N, H) # ReLU 的导数就是 0/1 掩码注意用 Z1 而不是 A1 判断 dZ1 dA1 * (Z1 0) # (N, H) dW1 X.T dZ1 # (D, N) (N, H) - (D, H) db1 dZ1.sum(axis0) # (H,) if l2 0: # L2 正则项 lam * ||W||^2 对 W 求导得 2 * lam * W dW2 2.0 * l2 * params[W2] dW1 2.0 * l2 * params[W1] return {W1: dW1, b1: db1, W2: dW2, b2: db2}几个容易错的点逐条说。dlogits / N的位置。因为损失是mean而不是sum1/N 必须进到梯度里。放这里最省事后面所有梯度都自然带着这个系数。如果放到最后统一除很容易漏掉db2、db1这两个求和项结果是权重学得对、偏置学得慢 N 倍表现出来就是 loss 下降但收敛点偏移很难查。dZ1 dA1 * (Z1 0)里必须用Z1不能用A1 0。虽然 ReLU 之后A1 0和Z1 0在正区间是等价的但A1已经被截断成 0A1 0也能得到同样结果——那为什么强调因为如果你中途把激活换成 LeakyReLU 或 GELUA1 0就彻底错了而Z1 0至少还有一半对。养成用预激活值判断的习惯换激活时少踩一次坑。dW1的形状靠转置自然对上X.T是(D, N)dZ1是(N, H)相乘得(D, H)和W1一致。如果你验算梯度时发现形状对不上不要试凑转置先回去检查前面每个中间量的形状注释——试凑转置出来的梯度经常也能让 loss 降因为它在数学上等价于换了一个模型。L2 的系数2.0 * l2也对。有些实现里正则项写成(lam/2) * ||W||^2那梯度就是lam * W不带 2。两种写法都对但你在调l2参数时要知道自己用的是哪套否则同一个 0.01 在两个实现里强度差一倍调参经验没法迁移。W需要正则、b不需要这是通用做法。偏置正则化在样本量小时会明显压住模型的拟合能力尤其是类别不平衡的数据集偏置被拉向 0 会让预测整体偏向多数类。2.4 初始化与激活选型全零为什么必死He 和 Xavier 怎么挑权重初始化不是玄学本质是控制每层输出的方差别让它随层数指数放大或缩小。全零初始化的问题很直接隐层所有神经元初始状态完全相同前向输出一样反向梯度也一样更新之后还是一样等于你花了 64 个神经元的算力只训了 1 个。def init_params(n_in, n_hidden, n_out, rng, scalehe): if scale he: # 配合 ReLU方差 2/fan_in补偿 ReLU 砍掉一半负值带来的方差损失 s1 np.sqrt(2.0 / n_in) s2 np.sqrt(2.0 / n_hidden) else: # Xavier配合 tanh / sigmoid前后向方差都照顾 s1 np.sqrt(1.0 / n_in) s2 np.sqrt(1.0 / n_hidden) return { W1: rng.normal(0, s1, size(n_in, n_hidden)), b1: np.zeros(n_hidden), # 偏置可以全零不破坏对称性 W2: rng.normal(0, s2, size(n_hidden, n_out)), b2: np.zeros(n_out), }这里用rng.normal(0, std, size...)标准差就是sqrt(2/fan_in)方差是2/fan_in和 He 初始化的定义一致。fan_in是这一层的输入维度不是输出维度——写错这个深一点的网络第一轮就梯度消失或爆炸。激活函数的选择上隐层默认 ReLU理由不是「效果好」这种空话而是它在正区间的导数恒为 1梯度回传时不会像 sigmoid 那样每层乘一个小于 0.25 的数。四层以上用 sigmoid 做隐层梯度连乘下来基本归零表现出来就是前几层权重几乎不动、loss 下降极慢很多人误判成学习率太小把 lr 从 1e-3 调到 1e-1结果直接发散。有一个场景值得把 ReLU 换掉隐层单元数很少比如 8 个且数据里有大量样本落在负区间时ReLU 会「死」掉一片神经元之后再也不更新。判断方法是看训练过程中有多少个隐层单元的输出恒为 0超过三成就要考虑换 LeakyReLU负半轴给个 0.01 的斜率或者减小学习率。输出层永远不加激活直接给 logitssoftmax 在损失函数那一步做这样数值上更稳。3. 训练循环mini-batch、优化器与学习率的参数怎么定前向反向能跑通只说明数学对了。真正决定这份实现好不好用的是训练循环怎么写数据怎么切、batch 多大、优化器选谁、学习率给多少、什么时候停。这一章把这几个旋钮一个个拆开每个都给出可复现的默认值和不work 时的调整方向。3.1 数据准备标准化用谁的标准差标准化必须先切分再统计这是最容易泄漏的地方。正确顺序是先划分训练集和验证/测试集然后只用训练集的均值和标准差去变换训练集再用同一组统计量变换验证集。def standardize(train_X, other_X_list): mu train_X.mean(axis0) sigma train_X.std(axis0) sigma[sigma 1e-8] 1.0 # 常数列不要除零 norm_train (train_X - mu) / sigma norm_others [(x - mu) / sigma for x in other_X_list] return norm_train, norm_others, mu, sigma如果图省事用全量数据算均值和标准差验证集的信息就提前漏进了训练过程指标会虚高几个点上线之后原形毕露。这个错误在表格数据上特别隐蔽因为特征维度低、泄漏量小验证集准确率只高 1 到 2 个点看着像正常波动。sigma里那个1e-8的判断也别省。数据里出现某一列全是一样值比如某个标志位在采样窗口内没变过时标准差是 0除下去整列变inf或nan然后 loss 一路 nan排查半天发现是数据问题不是模型问题。还有一个可选步骤如果特征量纲差异大到几十倍比如一列是金额、一列是比例标准化是必须的如果所有特征本来就在 0 到 1 之间标准化影响不大但做了也没坏处。唯一不能做的是对类别型特征做标准化那没有意义应该先做 one-hot 或者目标编码。3.2 mini-batch 与 epoch采样、shuffle 和最后一个不完整批次def iterate_minibatches(X, y, batch_size, rng, shuffleTrue): N X.shape[0] idx rng.permutation(N) if shuffle else np.arange(N) for start in range(0, N, batch_size): batch idx[start:start batch_size] yield X[batch], y[batch]rng.permutation(N)每个 epoch 重新打乱一次这一步不能省。不打乱的话如果数据是按类别或按时间排好序的每个 batch 的梯度方向会高度相关模型会先学后面的类别、再学前面的收敛曲线呈阶梯状。更糟的情况是数据按时间排序且分布漂移不打乱等于在做一个奇怪的课程学习。最后一个 batch 通常不满batch_size这里不丢弃也不补齐直接让它小一点。因为损失是mean小 batch 的梯度尺度会自动归一化不需要额外处理。唯一的副作用是 BatchNorm 之类的层在小 batch 上方差估计不稳——但这份实现里没有 BN所以无所谓。batch_size的默认值我一般取 64。样本量小于 2000 时用 32梯度噪声大一点反而有正则效果样本量上万时可以到 128 或 256配合学习率同步放大大致按lr * batch_size / 64缩放这是线性缩放经验不是铁律。batch 太小比如 4会让 loss 曲线抖得像心电图看不出趋势batch 太大接近 N等于全批量梯度下降每个 epoch 只更新一次收敛慢且容易卡在鞍点。3.3 优化器SGD、Momentum、Adam 的十行实现与参数含义SGD 就是param - lr * grad一行。它在简单问题上够用但在损失面狭长不同方向曲率差很多时会来回震荡这时 Momentum 有用给每个参数维护一个速度项把历史梯度累积起来。class SGD: def __init__(self, lr1e-2): self.lr lr def step(self, params, grads): for k in params: params[k] - self.lr * grads[k] class Adam: def __init__(self, params, lr1e-3, betas(0.9, 0.999), eps1e-8): self.lr lr self.b1, self.b2 betas self.eps eps self.t 0 self.m {k: np.zeros_like(v) for k, v in params.items()} # 一阶矩 self.v {k: np.zeros_like(v) for k, v in params.items()} # 二阶矩 def step(self, params, grads): self.t 1 for k in params: self.m[k] self.b1 * self.m[k] (1 - self.b1) * grads[k] self.v[k] self.b2 * self.v[k] (1 - self.b2) * grads[k] ** 2 # 偏差修正早期 t 很小时 m、v 都偏向 0直接除会放大步长 m_hat self.m[k] / (1 - self.b1 ** self.t) v_hat self.v[k] / (1 - self.b2 ** self.t) params[k] - self.lr * m_hat / (np.sqrt(v_hat) self.eps)偏差修正那两行是 Adam 里最容易被抄漏的部分。漏掉之后第一个 step 的v只有真实二阶矩的 0.1%sqrt(v)极小除以它之后步长被放大几十倍参数一步飞出去loss 直接 nan。很多人第一次手写 Adam 就是这个现象然后误以为是学习率给大了。参数含义上b10.9控制一阶矩的记忆长度大约相当于最近 10 个 step 的加权平均b20.999控制二阶矩记忆更长对梯度尺度的估计更平滑eps1e-8是防除零的一般不用改除非你用 float16 训练。三种优化器的选择建议如下表。优化器推荐 lr适用场景主要问题SGD1e-2 ~ 1e-1小数据、想严格控制收敛行为对 lr 敏感狭长损失面震荡Momentum1e-2 ~ 5e-2中等规模、收敛慢但稳定需要多调一个动量系数Adam1e-3 ~ 3e-3默认首选尤其是稀疏特征后期泛化有时略差于调好的 SGD我的习惯是先用 Adam 1e-3 跑通确认模型能学到东西再换成 SGD 试能不能把验证集指标再往上顶一两个点。反过来做先用 SGD 调半天通常是在浪费时间因为 SGD 不收敛时你分不清是 lr 不对还是实现有 bug。3.4 正则与早停weight decay、dropout 和验证集监控L2 正则前面已经在 backward 里加了l21e-4是个温和的默认值。它的作用是把权重往 0 拉等价于限制模型复杂度。l2太大1e-2 以上会出现训练集和验证集指标一起降说明模型被压得学不动了太小1e-6基本等于没加。Dropout 是另一种正则在训练时随机把一部分隐层单元置零。纯 NumPy 实现要注意用 inverted dropout也就是训练时除以保留概率这样预测时不需要做任何缩放def forward_train(params, X, drop_p, rng): Z1 X params[W1] params[b1] A1 relu(Z1) if drop_p 0: # 保留概率 1-drop_p被保留的单元放大 1/(1-drop_p) mask (rng.random(A1.shape) drop_p) / (1.0 - drop_p) A1 A1 * mask else: mask None logits A1 params[W2] params[b2] probs softmax(logits) return probs, (X, Z1, A1, probs, mask)反向时dA1要先乘同一个mask因为被丢弃的单元在前向时没有贡献梯度也不该回传。这个 mask 必须存进 cache每个 batch 重新采样不能在 epoch 之间复用。drop_p在表格数据上一般取 0.1 到 0.3超过 0.5 通常欠拟合。早停比 dropout 更实用成本也更低。做法是每个 epoch 结束算一次验证集 loss连续patience个 epoch 没有下降就停并回滚到验证 loss 最低那轮的参数def fit(model, X, y, X_val, y_val, epochs100, batch_size64, lr1e-3, l21e-4, patience8, seed0): rng np.random.default_rng(seed) opt Adam(model.params, lrlr) best_loss, wait, best_snapshot np.inf, 0, None for epoch in range(epochs): for xb, yb in iterate_minibatches(X, y, batch_size, rng): probs, cache forward(model.params, xb) grads backward(model.params, cache, yb, l2l2) opt.step(model.params, grads) val_probs, _ forward(model.params, X_val) val_loss cross_entropy(val_probs, y_val) if val_loss best_loss - 1e-5: best_loss, wait val_loss, 0 # 深拷贝参数避免回滚时引用被后续更新污染 best_snapshot {k: v.copy() for k, v in model.params.items()} else: wait 1 if wait patience: break if best_snapshot is not None: model.params best_snapshot return modelbest_snapshot必须copy()这是个经典坑直接best_snapshot model.params只是复制了字典的引用后续每个 step 都在原地改同一批数组回滚时拿到的是最后一轮的参数早停等于没做。4. 训练不收敛先别改模型五类翻车现场和排查顺序这份代码跑不通的时候绝大多数问题不在数学推导在数值和数据。下面五条是我自己踩过、也帮别人定位过的按「现象 → 原因 → 解决」写建议按顺序排查不要跳。4.1 损失不动或直接变 nan先查数值再查学习率现象一loss 从第一个 epoch 开始就卡在 2.302 左右不动。2.302 是ln(10)意味着模型在 10 分类任务上输出完全均匀的概率也就是每个类 0.1。原因通常是参数根本没更新——检查优化器step有没有真的被调用、params传进去的是不是同一个字典对象。另一个常见原因是标签和数据错位y的顺序和X的行对不上模型学不到任何东西只能退化成均匀分布。解决方法是先用 20 个样本过拟合一遍如果 loss 降不到 0.01 以下实现一定有 bug别往下调参。现象二训练几十步之后 loss 突然变成 nan。按概率从高到低排softmax 没减最大值导致exp溢出、学习率过大导致参数爆炸、log(0)、某一列特征标准差为 0 导致除零。定位手段是在backward里加一句断言检查每个梯度的np.isfinitefor k, g in grads.items(): assert np.all(np.isfinite(g)), f梯度 {k} 出现非有限值这一步能立刻把问题锁定到是哪一层的梯度先炸的。如果dlogits就已经是 nan问题在前向如果只有dW1是 nan问题在X或者dZ1。4.2 准确率卡在类别比例查初始化与标签格式现象三三分类数据集准确率一直在 0.33 附近晃loss 降得很慢。先看是不是死 ReLU统计隐层输出中恒为 0 的单元比例超过一半说明大量神经元在初始化后就被推到了负区间再也回不来。原因通常是学习率偏大加上初始化尺度不对。解决办法是把 lr 降到 1e-3 甚至 3e-4或者把激活换成 LeakyReLU。另一个原因是初始化用了scalexavier但激活是 ReLU方差偏小前几层输出接近 0梯度也接近 0。换成 He 初始化一般就正常了。现象四loss 正常下降但准确率完全不涨甚至低于随机猜。十有八九是标签格式错。backward里用的是dlogits[np.arange(N), y] - 1这要求y是 0 到 C-1 的整数。如果传进来的是 one-hot 矩阵probs[np.arange(N), y]会报索引维度错误如果传进来的是 1 到 C 的整数从 1 开始编号那正确类别永远差一位模型学到的全是错的。解决办法是在fit开头加一句y y.astype(np.int64); assert y.min() 0 and y.max() C。4.3 训练集漂亮验证集拉胯查泄漏与评估口径现象五训练集准确率 0.98验证集 0.62加大正则也没用。先排除数据泄漏的反向版本——不是验证集漏进训练而是训练集和验证集的划分本身有问题。如果数据是按用户或按会话采集的随机按行切分会让同一个用户的行为同时出现在两边模型记住用户特征就能拿高分。正确做法是按用户 ID 分组切分。这一点在表格数据上比在图像上隐蔽得多因为特征看起来都是数值看不出分组结构。排除划分问题之后才是过拟合。此时的动作顺序是先加l21e-4 到 1e-3再看样本量够不够每类少于 200 个样本时单隐层网络很容易过拟合考虑减 H最后才考虑 dropout。反过来先上 dropout 常常把训练集指标也压下去让你误判成欠拟合。还有一条容易被忽略早停用的验证集不能同时用来报最终指标。用验证集选完超参再在同一个验证集上报准确率数字会偏乐观。严谨一点的做法是切三份训练、验证早停和调参、测试只在最后跑一次。5. 封装与复现从 CSV 到能跑第二次的完整流程代码能跑通和能反复用是两回事。一份手写实现如果每次都靠 notebook 里从上到下执行第二次想复现就得到处找变量。这一章把它收成一个类补上权重存取和与成熟库的对齐验收。5.1 用一个类收口fit / predict_proba / predict封装的目标不是「面向对象」是让标准化参数、网络参数、超参绑定在一起不会出现「用 A 的均值标准化了 B 的数据」这种事故。class MLPClassifier: def __init__(self, hidden64, n_classes2, lr1e-3, l21e-4, epochs100, batch_size64, patience8, seed0): self.hidden hidden self.n_classes n_classes self.lr, self.l2 lr, l2 self.epochs, self.batch_size epochs, batch_size self.patience, self.seed patience, seed self.params None self.mu None self.sigma None def fit(self, X, y): X np.asarray(X, dtypenp.float64) y np.asarray(y).astype(np.int64) # 标准化统计量只从训练数据来并保存下来给 predict 用 self.mu X.mean(axis0) self.sigma X.std(axis0) self.sigma[self.sigma 1e-8] 1.0 Xs (X - self.mu) / self.sigma rng np.random.default_rng(self.seed) self.params init_params(Xs.shape[1], self.hidden, self.n_classes, rng, scalehe) # 从训练集里再切一小块做早停监控 n_val max(1, int(len(Xs) * 0.15)) order rng.permutation(len(Xs)) val_idx, tr_idx order[:n_val], order[n_val:] fit(self, Xs[tr_idx], y[tr_idx], Xs[val_idx], y[val_idx], epochsself.epochs, batch_sizeself.batch_size, lrself.lr, l2self.l2, patienceself.patience, seedself.seed) return self def predict_proba(self, X): Xs (np.asarray(X, dtypenp.float64) - self.mu) / self.sigma probs, _ forward(self.params, Xs) return probs def predict(self, X): return self.predict_proba(X).argmax(axis1)fit里最后那次调用和第 3 章的fit函数同名注意命名冲突——实际工程里我会把类方法叫_fit_loop这里为了对照清楚保留原名。np.asarray(..., dtypenp.float64)这步看着多余但它拦住了一个高频问题传进来的X是int或float32和float64的权重相乘后类型提升某些操作会静默降精度或者X.T dZ1因为 dtype 不一致报错。predict_proba里必须用训练时保存的self.mu和self.sigma不能重新算。这条如果写错线上推理和离线评估的指标会差出一大截而且极难发现因为两边都不报错。5.2 权重存取与版本对齐npz 里该存什么只存权重是不够的。下次加载时如果不知道当时的标准化参数和隐层宽度模型就没法用。def save(self, path): np.savez( path, W1self.params[W1], b1self.params[b1], W2self.params[W2], b2self.params[b2], muself.mu, sigmaself.sigma, hiddennp.int64(self.hidden), n_classesnp.int64(self.n_classes), ) def load(self, path): z np.load(path) self.hidden int(z[hidden]) self.n_classes int(z[n_classes]) self.mu, self.sigma z[mu], z[sigma] self.params {k: z[k] for k in (W1, b1, W2, b2)} return self加载之后建议做一次形状自检W1.shape应该是(D, hidden)如果待预测数据的特征维度 D 和它对不上直接抛异常比让它广播出一个错误结果好。np.savez是未压缩的权重只有几十 KB 时无所谓上百 MB 时换np.savez_compressed代价是加载慢一点。顺带提一句权重文件要跟代码一起进版本管理或者至少在文件名里带上训练日期和数据版本。我见过最难受的情况是线上效果掉了回头找当时的模型发现存档文件被同名覆盖过三次根本不知道现在跑的是哪一版。5.3 和 sklearn 对齐验收同一份数据、同一个切分、同一套指标自己写的实现到底对不对最省事的验收方式是和一个成熟实现对比。用同一份数据、同一个训练/测试切分、同一个随机种子跑两边看指标差距。from sklearn.neural_network import MLPClassifier as SkMLP from sklearn.model_selection import train_test_split from sklearn.metrics import accuracy_score, log_loss X_tr, X_te, y_tr, y_te train_test_split(X, y, test_size0.2, random_state42, stratifyy) mine MLPClassifier(hidden64, n_classeslen(np.unique(y)), seed42) mine.fit(X_tr, y_tr) p_mine mine.predict_proba(X_te) ref SkMLP(hidden_layer_sizes(64,), activationrelu, solveradam, learning_rate_init1e-3, max_iter300, random_state42) ref.fit(X_tr, y_tr) p_ref ref.predict_proba(X_te) print(mine acc:, accuracy_score(y_te, p_mine.argmax(1)), logloss:, log_loss(y_te, p_mine)) print(ref acc:, accuracy_score(y_te, p_ref.argmax(1)), logloss:, log_loss(y_te, p_ref))两边准确率差 1 到 2 个点属于正常因为初始化、batch 顺序、早停策略都不一样。差 10 个点以上就要查自己这边了通常落在三个地方标准化没做或做错、标签格式不对、早停回滚失效。log_loss比准确率更敏感两边差 0.05 以上基本能确认有问题因为准确率在类别不平衡时会掩盖概率校准的偏差。stratifyy在小样本多分类时不能省否则切出来的测试集里可能根本没有某个类别评估结果的方差会大到没法比较。6. 给实现上保险用数值梯度校验把反向传播钉死反向传播写错最麻烦的地方在于它经常「错得能跑」——loss 会降指标会涨只是比正确版本低几个点你永远不知道是自己调参不行还是代码有 bug。所以我现在每写一次新的反向传播都会先跑一遍数值梯度校验再谈训练的事。原理很简单对某个参数做微小扰动用前向传播算两次损失差商就是数值梯度和解析梯度比相对误差小于1e-6就算通过。关键是用中心差分加减 eps不要用前向差分只加 eps中心差分的截断误差是 O(eps²)精度高一个量级。def grad_check(seed0, n6, d5, h4, c3, eps1e-4): rng np.random.default_rng(seed) params init_params(d, h, c, rng, scalehe) X rng.normal(size(n, d)) y rng.integers(0, c, sizen) def loss_fn(): probs, _ forward(params, X) return cross_entropy(probs, y) probs, cache forward(params, X) grads backward(params, cache, y, l20.0) for key in params: idx tuple(rng.integers(0, s) for s in params[key].shape) old params[key][idx] params[key][idx] old eps f_plus loss_fn() params[key][idx] old - eps f_minus loss_fn() params[key][idx] old # 无论中间发生什么都要还原 num (f_plus - f_minus) / (2 * eps) ana grads[key][idx] rel abs(num - ana) / max(1e-8, abs(num) abs(ana)) print(f{key}{idx}: numeric{num:.8e} analytic{ana:.8e} rel{rel:.2e})跑这段前有几个前提必须满足否则校验一定会失败而且失败得很冤枉。第一数据类型用float64float32下 eps 取 1e-4 会被舍入误差吃掉相对误差天然就在 1e-3 量级。第二校验时把l2设为 0、关掉 dropout 和任何随机性因为数值梯度的两次前向必须看到完全一样的网络。第三网络规模要小n、d、h、c 都取个位数校验的是公式正确性不是性能。第四损失函数里不要用np.clip之外的任何数值保护如果probs里有接近 0 的值把 eps 调大到 1e-3 再试。验收标准我一般卡相对误差小于1e-6。如果某个参数的误差在1e-4到1e-2之间通常是某一项漏了1/N或者多乘了 2误差在 0.1 以上基本是符号反了或者转置错了。还有个技巧先单独校验输出层只对W2、b2通过了再校验隐层。分层校验比一次性全校验容易定位问题因为输出层的梯度公式最简单。有一个坑我踩过不止一次校验通过之后立刻去跑完整训练结果 loss 变 nan然后回头怀疑校验没用。实际情况是校验用的是小网络、float64、无正则而训练用的是几百维特征加上 L2问题出在数值范围而不是公式。所以校验之后我会再补一步「单 batch 过拟合」取 32 个样本把l2关掉训练 500 步确认 loss 能降到 0.01 以下。这两道关都过了才敢往全量数据上跑。从那以后我每次改动损失函数、换激活、加正则项都会强制走一遍梯度校验加单 batch 过拟合两个加起来不到两分钟但省下的排查时间是以小时计的。希望帮到你。本文还有配套的精品资源点击获取