简介:一份用Python与NumPy从零搭建全连接神经网络的入门实现,面向机器学习初学者和希望理解网络内部机制的开发者。资源以7个py脚本组织,整体仅4KB,包含网络结构定义、sigmoid激活函数、随机权重初始化、前向传播、均方误差损失、反向传播、训练循环与预测等完整模块,另附make_data与predict等数据生成和预测演示脚本,便于对照运行、修改和排查。代码按步骤拆分、结构清晰,可在动手过程中掌握权重初始化、链式法则求导、梯度更新等核心知识点,弥补直接调用深度学习框架时容易忽略的底层细节。已有4211人学习下载,适合作为入门深度学习的练手资料,也可为后续迁移到TensorFlow、Keras或PyTorch等框架打下扎实基础。
1. 简单全连接神经网络python实现:真正值钱的不是“会调库”
用Python从零实现一个简单全连接神经网络,是很多人在深度学习中遇到的第一个真正难点。说它“简单”,是因为网络结构就那几层:输入层、隐藏层、输出层,矩阵乘法加激活函数;说它“难”,是因为反向传播一旦写错,整个模型要么不收敛,要么一直在原地打转,而你不一定能察觉到问题出在哪个矩阵上。我见过不少能熟练调sklearn和PyTorch的人,一让手写一个全连接网络就懵了——倒不是不会写前向传播,而是对背后的链式法则、梯度形状、初始化这些细节缺乏手感。
这篇文章会带你用纯NumPy实现一个三层全连接网络,拿到真实数据集上训练并验证效果,同时把初始化、学习率、batch_size这些参数的选择逻辑讲清楚。适合刚入门机器学习、想弄明白神经网络黑匣子、或者工作中经常处理结构化数据的Python工程师。读完你可以直接抄代码跑通,也能顺手给自己已有的小项目换上一个不依赖复杂框架的基线模型。
2. 全连接网络的结构与选型:先弄懂每个参数在为什么服务
2.1 全连接层:一个矩阵乘法加一个偏置就完事
全连接层做的事,用一行式子就能说清:Y = X·W + b。输入X是形状为(batch, in_features)的矩阵,权重W是形状为(in_features, out_features)的矩阵,偏置b是形状为(1, out_features)的向量,相加时NumPy会自动广播到每一行。这个“全连接”三个字的含义也很直白:上一层每一个节点,都和下一层每一个节点有一条带权重的连线。
举个例子,用sklearn的digits手写数字数据集,每个样本是8×8像素图,展平后得到64个特征。如果第一个隐藏层设64个神经元,那么这一层的权重矩阵W1形状就是(64, 64),前向传播就是X @ W1 + b1。这个矩阵乘法一次就能完成全部样本从输入到隐藏层的线性变换,比写for循环逐个样本算高效得多。
落地时要特别注意输入特征数和输出类别数。输入层节点数必须等于特征维度,所以digits就是64;输出层节点数等于类别数,digits是10个数字,所以输出层是10。隐藏层节点数则是你唯一可以自由设计的变量,这也是全连接网络最直接可调的“容量旋钮”。
2.2 激活函数与输出层设计:ReLU和Softmax的搭配逻辑
如果没有激活函数,多层全连接网络叠加起来仍然是一个线性变换,隐藏层再多也白搭。激活函数的关键作用就是把非线性引入模型。常见选择有三个:Sigmoid、Tanh和ReLU。Sigmoid和Tanh的问题是两侧梯度趋近于零,层一多梯度连乘后基本消失;ReLU在正区间梯度恒为1,训练稳定得多,因此现在隐藏层几乎默认用ReLU,这也是我的第一选择。
| 函数 | 公式 | 适用位置 | 主要坑 |
|---|---|---|---|
| Sigmoid | 1 / (1 + e^-x) | 旧式隐藏层 | 梯度消失严重,输出不以0为中心 |
| Tanh | (e^x - e^-x) / (e^x + e^-x) | 旧式隐藏层 | 同样有梯度消失,但比Sigmoid略好 |
| ReLU | max(0, x) | 隐藏层 | 负区间梯度为0,学习率过大容易“死亡” |
| Softmax | e^x / Σe^x | 多分类输出层 | 数值太大时溢出,需要减去最大值 |
隐藏层用ReLU,输出层做多分类时用Softmax把原始得分转成概率分布。如果你有一个回归任务,输出层就不加激活函数,直接输出线性结果。层与层之间的搭配逻辑一般是:中间层统一用ReLU,最后一层根据任务类型选择Softmax或线性。千万不要在隐藏层用Softmax,那会让网络很难优化,这是一个新手很爱踩的坑。
2.3 损失函数与梯度下降:训练全连接网的目标函数
分类任务里,最常用的损失函数是交叉熵。它衡量预测概率分布和真实标签one-hot编码之间的差距,公式是L = -Σ y_i·log(p_i),其中y_i是真实标签的one-hot值,p_i是模型预测的第i类概率。多分类任务里,交叉熵配合Softmax能形成非常漂亮的梯度形式:输出层的误差项直接等于(pred - y_onehot),这让反向传播代码简洁很多。而MSE配合Softmax则容易让梯度变得很小,训练明显变慢,所以分类任务别用MSE。
梯度下降的更新逻辑也不复杂:每个参数沿着损失函数梯度的反方向挪一小步,W = W - 学习率 × dW。学习率决定每一步的跨度,跨度太大容易震荡甚至发散,跨度太小则训练半天不见 loss 下降。实践里我一般先把学习率设在0.01附近,观察前几十个epoch的loss曲线再决定要不要调。不要一上来同时调学习率、batch_size和层数,那样出了问题根本分不清是哪个参数引起的。
2.4 为什么先手写一个无关框架的全连接网络
很多人会问,PyTorch一行nn.Linear就能建层,为什么还要手搓?因为框架把最关键的梯度计算封装成了黑匣子。工作中遇到模型不收敛,如果完全不懂反向传播,你只能瞎调超参数;懂了梯度从哪来,就能直接判断是初始化问题、梯度消失问题,还是数据缩放问题。比如我在做结构化数据的快速建模时,经常用全连接网络跑一个基线,再用树模型对比。这时候手写NumPy版本虽然不如PyTorch方便,但好处是没有任何警告提示干扰,逻辑全在自己手里。
另外,量化交易特征、回归预测这类结构化数据场景,往往不需要复杂的CNN、Transformer,一个两到三层的全连接网络就能拿到足够好的基线效果。这时候你有两个选择:一是调sklearn的MLPClassifier,二是自己写一个几十行的NumPy版本。调库很快但难深入,手写能让你清楚每一步在干什么,后续想加正则化、动量或自定义损失也都在射程范围内。先手写再迁移到框架,是我认为最稳的学习路径。
2.5 落地到你的场景:层数、神经元数与起步参数
如果你今天就要做自己的第一个全连接网络,我一般按这个顺序来定参数:
- 输入层节点数:等于特征维度,不需要调。
- 输出层节点数:等于类别数;回归任务则为1。
- 隐藏层层数:先试一层,不行再试两层;三层以上在结构化数据上收益很小,还会大幅增加训练时间。
- 隐藏层节点数:从输入维度和输出维度的中间值起步,比如64→64→10,再往128或256扩。
- 学习率:从0.01起步;loss震荡就降到0.001,loss下降太慢就提到0.1。
- 批大小:数据量小可以用全量梯度下降,数据量超过一万条再考虑64或128的mini-batch。
这套起步参数不需要太多调参经验,也基本能让网络跑出有意义的loss下降曲线。很多人一上来就堆一个1024神经元的大网络,结果在小数据集上疯狂过拟合,还怪模型不行。记住:结构简单、数据量小的时候,小网络往往比大网络更靠谱,这也是“简单全连接网络”存在的最大价值。
3. 用NumPy从零实现全连接网络:三个代码块讲清核心
3.1 只依赖NumPy:先定好网络结构和初始化
在写前向传播之前,先解决权重初始化问题。全零初始化会让所有神经元学到完全相同的特征,随机初始化则能让每个神经元一开始就分化。配ReLU激活函数时,我常用He初始化:权重从标准差为sqrt(2 / fan_in)的正态分布中取样。这样能让每一层输出的方差在传播过程中保持稳定,不会一层比一层小到消失。
import numpy as np def init_weights(in_nodes, out_nodes): """He初始化,适配ReLU激活函数""" return np.random.randn(in_nodes, out_nodes) * np.sqrt(2.0 / in_nodes) def init_network(input_dim=64, hidden_dim=64, output_dim=10): """初始化一个两层隐藏层的全连接网络参数""" W1 = init_weights(input_dim, hidden_dim) b1 = np.zeros((1, hidden_dim)) W2 = init_weights(hidden_dim, hidden_dim) b2 = np.zeros((1, hidden_dim)) W3 = init_weights(hidden_dim, output_dim) b3 = np.zeros((1, output_dim)) return {'W1': W1, 'b1': b1, 'W2': W2, 'b2': b2, 'W3': W3, 'b3': b3}这段代码把全部参数放进一个字典里,方便后面按名字取用。偏置全部初始化为0是安全的,因为权重已经打破了对称性。参数说明:input_dim是输入特征数,hidden_dim是隐藏层神经元数,output_dim是输出类别数。如果你想增大网络容量,优先改hidden_dim;如果想减少过拟合,则把它调小。
3.2 前向传播:矩阵乘法、ReLU、Softmax一路算到底
前向传播就是把输入一层层送进去:z1 = X@W1 + b1,过ReLU得到a1;再送到第二层,第三层用Softmax输出概率。Softmax分母是一个求和项,当输入数值很大时,e的指数会溢出成inf,所以我在实现里先减去每行的最大值再算指数。这个操作不改变概率结果,只为了让数值计算稳定。
def relu(x): """ReLU激活函数,负区间归零""" return np.maximum(0, x) def relu_grad(x): """ReLU的导数,正数区为1,负数区为0""" return (x > 0).astype(float) def softmax(z): """带数值稳定的Softmax,按行计算概率分布""" z = z - np.max(z, axis=1, keepdims=True) exp_z = np.exp(z) return exp_z / np.sum(exp_z, axis=1, keepdims=True) def forward(X, params): """前向传播:返回中间变量cache和最终概率probs""" W1, b1 = params['W1'], params['b1'] W2, b2 = params['W2'], params['b2'] W3, b3 = params['W3'], params['b3'] z1 = X @ W1 + b1 a1 = relu(z1) z2 = a1 @ W2 + b2 a2 = relu(z2) z3 = a2 @ W3 + b3 probs = softmax(z3) cache = {'X': X, 'z1': z1, 'a1': a1, 'z2': z2, 'a2': a2, 'z3': z3} return probs, cache逻辑说明:z1和a1的形状都是(batch, 64),z3和probs的形状是(batch, 10)。cache里保存的是反向传播要用的中间结果。参数说明:这段代码里X是(batch, 64)的二维数组,不是单个样本;无论如何都不要在中间层用Softmax,否则梯度会被压缩得很厉害,前向传播跑得再对也没用。
3.3 反向传播:链式法则写成形状匹配的逐行流水线
反向传播是整个手写网络的核心,也几乎是所有人第一次翻车的地方。要从损失函数开始倒推,逐层求出每个参数的梯度。借助Softmax + 交叉熵的化简结论,第三层的误差项等于probs减去one-hot标签再除以batch_size;随后第二层、第一层的误差通过权重转置往回传,并在经过ReLU时乘以对应的梯度门控。
def backward(probs, y_onehot, params, cache): """反向传播:返回各参数梯度""" m = probs.shape[0] # batch样本数 X, a1, a2, z1, z2 = cache['X'], cache['a1'], cache['a2'], cache['z1'], cache['z2'] # 输出层梯度,softmax+cross_entropy化简结果 dz3 = (probs - y_onehot) / m dW3 = a2.T @ dz3 db3 = np.sum(dz3, axis=0, keepdims=True) # 第二隐藏层梯度 da2 = dz3 @ params['W3'].T dz2 = da2 * relu_grad(z2) dW2 = a1.T @ dz2 db2 = np.sum(dz2, axis=0, keepdims=True) # 第一隐藏层梯度 da1 = dz2 @ params['W2'].T dz1 = da1 * relu_grad(z1) dW1 = X.T @ dz1 db1 = np.sum(dz1, axis=0, keepdims=True) return {'W1': dW1, 'b1': db1, 'W2': dW2, 'b2': db2, 'W3': dW3, 'b3': db3}逻辑说明:每一行梯度都要求结果形状和对应参数形状完全一致,比如dW3要和W3同形状,所以用a2.T @ dz3而不是dz3.T @ a2,这一点非常容易写反。db用np.sum加上keepdims=True,保证偏置形状依然是(1, hidden)。参数说明:y_onehot是one-hot编码后的(batch, 10)矩阵,分母除以m是把梯度假定为对批内样本的平均,这样当batch_size变化时,学习率可以不需要大幅度调整。
3.4 把参数更新和批次循环串起来:一个完整的Mini-batch训练骨架
有了forward和backward,训练循环就是把这两个函数反复调用,再按梯度更新参数。下面给出一个可直接运行的训练骨架,包含mini-batch切分、数据打乱和SGD更新。这里的核心是shuffle必须在每个epoch前重新做一次,保证每个batch的内容不完全一样,能让梯度下降收敛得平稳一些。
def train(X_train, y_train, hidden_dim=64, lr=0.01, epochs=200, batch_size=64, seed=42): """完整的全连接网络mini-batch训练流程""" np.random.seed(seed) n, input_dim = X_train.shape num_classes = y_train.shape[1] params = init_network(input_dim, hidden_dim, num_classes) for epoch in range(epochs): # 每个epoch重新打乱数据 idx = np.random.permutation(n) X_shuffled, y_shuffled = X_train[idx], y_train[idx] for start in range(0, n, batch_size): end = start + batch_size X_batch, y_batch = X_shuffled[start:end], y_shuffled[start:end] probs, cache = forward(X_batch, params) grads = backward(probs, y_batch, params, cache) # SGD参数更新 for key in params: params[key] -= lr * grads[key] if epoch % 50 == 0: probs, _ = forward(X_train, params) acc = np.mean(np.argmax(probs, axis=1) == np.argmax(y_train, axis=1)) print(f"epoch {epoch}, train acc {acc:.4f}") return params逻辑说明:内层循环按batch_size从打乱后的数据里切片,每片做一个forward和backward,然后立刻更新参数。print放在每50个epoch之后,只为了观察训练趋势,不会拖慢整体速度。参数说明:lr=0.01和epochs=200是digits数据集上比较稳健的起步配置;如果你的loss下降太慢,可以先判断是学习率问题还是网络容量问题,不要一口气同时改两个量。
4. 用真实数据集训练你的全连接网络:数据准备与结果验证
4.1 准备数据集:用digits而不是MNIST,省掉下载步骤
很多人学神经网络首选MNIST,但MNIST数据集下载要处理格式和解压,很容易劝退初学者。这里换成sklearn自带的digits手写数字数据集,它也是“手写数字识别”,但样本量只有1797条,图片是8×8灰度的,训练速度秒级,非常适合验证一个简单全连接网络是否写对了。如果还想跑MNIST,只需把输入维度从64改成784,其他代码基本不用动。
from sklearn.datasets import load_digits from sklearn.model_selection import train_test_split digits = load_digits() X = digits.data / 16.0 # 像素值范围0~16,缩放到0~1 y = digits.target X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42, stratify=y ) def one_hot(y, num_classes): """把标签转成one-hot向量""" return np.eye(num_classes)[y] y_train_oh = one_hot(y_train, 10) y_test_oh = one_hot(y_test, 10) print(X_train.shape, y_train_oh.shape)逻辑说明:除以16.0这行非常重要。digits的像素原始值在0到16之间,如果不缩放,较大的输入会让加权求和结果偏大,ReLU输出要么饱和要么死亡,训练会变得极不稳定。参数说明:train_test_split里的stratify=y保证训练集和测试集各类别比例一致,测试集那20%不会都落在某一个数字上,评估准确率才有参考意义。
4.2 把前几章代码串起来:跑通一个最小可用的训练脚本
定义好数据之后,训练和评估的代码就短了。用训练函数返回的权重参数做预测,再用argmax取概率最大的类别,和真实标签比较计算准确率。整个流程下来大约100行代码,你就会得到一个能识别手写数字的简单网络。
# 训练 params = train(X_train, y_train_oh, hidden_dim=64, lr=0.01, epochs=200, batch_size=64) # 测试 probs_test, _ = forward(X_test, params) preds = np.argmax(probs_test, axis=1) acc = np.mean(preds == np.argmax(y_test_oh, axis=1)) print(f"test acc: {acc:.4f}")逻辑说明:train返回的params是已经训练好的全部权重和偏置;测试时用同一个forward再算一次前向传播,不需要也绝对不能调用backward。参数说明:如果测试准确率明显低于训练准确率,说明过拟合;如果两者都低,回头看训练过程中有没有输出nan,或者lr是否需要调整。我习惯在首次训练时打印test acc的同时,也打印几次中间epoch的数值,这样能快速分辨“还没收敛”和“已经过拟合”两种状态。
4.3 训练结果与超参数行为观察:学习率、batch_size、epoch怎么筛选
我自己调试时,最常见的一组行为是:学习率0.1在digits上会让loss震荡,训练后期准确率上蹿下跳;0.001非常稳但200个epoch还差一口气;0.01在mini-batch下很快就爬上去,是一个不错的起点。如果你发现loss下降很慢,优先检查学习率,而不是盲目加epoch。实践中可以先跑50个epoch看趋势,再决定是否继续。
| 学习率 | 200 epoch 后的典型表现 | 适合情况 |
|---|---|---|
| 0.1 | 前期冲得猛,后期震荡,准确率不稳定 | 调整后可能更快,但需要配学习率衰减 |
| 0.01 | 稳定收敛,训练和测试准确率同步上升 | 默认首选,大部分任务的起步值 |
| 0.001 | 下降缓慢,200 epoch可能不够 | 数据噪声大或已接近最优时微调用 |
batch_size的行为也很直观。batch越大,梯度越稳定,但每一步更新次数少;batch越小,更新频繁,曲线波动大。digits只有1400多个训练样本,64的batch其实就已经偏“大”了,因为每个batch覆盖了全量数据的1/23。如果你数据量特别大,比如几十万条,再考虑128或256。一定要记住:换了batch_size后学习率可能需要跟着调,这是下一章会展开的一个常见坑。
4.4 在自定义数据上直接可用的改法:回归任务就三行
如果你的实际项目不是分类,而是一个连续值预测,比如根据历史行情预测涨跌幅、根据用户特征预测转化率,改法非常简单:输出层去掉Softmax,输出层节点设为1,损失函数换成均方误差。前向传播和反向传播里,只有输出层的激活和梯度计算需要改。
def forward_regression(X, params): """回归版前向传播:输出层不加激活函数""" W1, b1 = params['W1'], params['b1'] W2, b2 = params['W2'], params['b2'] W3, b3 = params['W3'], params['b3'] z1 = X @ W1 + b1 a1 = relu(z1) z2 = a1 @ W2 + b2 a2 = relu(z2) y_pred = a2 @ W3 + b3 # 线性输出 return y_pred, a1, a2逻辑说明:回归版本的输出直接是连续值,形状为(batch, 1),反向传播时的输出层梯度要改成2*(y_pred - y_true)/m。参数说明:隐藏层和初始化完全不用变,唯一要额外注意的是,如果y_true的取值范围很大,最好先做均值方差归一化,否则MSE很容易变成nan。这一段改动足以覆盖量化策略特征、销售预测这类最常见的结构化数据机器学习任务。
5. 手写全连接网络避坑清单:五个常见翻车现场与排查顺序
5.1 损失变成NaN,准确率直接崩掉
现象:训练到某个epoch,损失输出变成nan,后面的准确率全是0.0。原因基本逃不开两个:学习率太大导致梯度更新步长过大,或者数据没有归一化使矩阵计算里的数值爆炸;还有一种隐蔽情况是交叉熵里的log对0取对数,Softmax虽然做了稳定化,但极端权重仍可能让某些概率近似等于0。解决方法是先检查数据缩放,把输入统一到[0,1];然后把学习率下调一个数量级,比如从0.1降到0.01;最后如果还在nan,考虑给梯度乘一个max_norm比例裁剪。这条是我自己踩过最多的坑,也是手写网络最典型的“血泪经验”。
5.2 所有样本输出同一个概率分布
现象:不管输入什么数字,模型预测结果的分布几乎一样,准确率一直停留在10%左右。原因通常是反向传播根本没把梯度送到最后一层,权重更新失效;或者初始化把所有权重设成了同一个值,导致每个神经元学到的特征完全一致。还有一个常见诱因是ReLU“死亡”:学习率过大,负区间梯度全为0,整个隐藏层不再更新。解决方法是先检查dw1里是否有大量0,如果有,降低学习率或把ReLU换成LeakyReLU;同时确认init_weights里用了随机初始化而不是zeros。检查顺序是从输出层往输入层走,先确认dW3和db3不为0,再逐层往前看。
5.3 训练集loss在降,验证集准确率一直不动
现象:前向传播和反向传播看起来都正常,训练集准确率能到95%以上,但在测试集上只能到80%多,甚至更低。原因就是过拟合——digits样本只有1797条,隐藏层一旦设到128以上,网络容量远超数据量,模型开始背训练样本,而不是泛化规律。解决方法是砍隐藏层节点数,把64降到32试试;或者加L2正则化,在梯度上给每个权重加penalty,参数更新变成W -= lr * (dW + lambda * W)。对结构化数据场景,数据量不够时优先缩小网络,远好过到处堆数据增强。
5.4 batch_size和学习率的配合问题
现象:把batch_size从64调到128后,loss曲线开始剧烈震荡,怎么调epoch都没有改善。原因是批量变大后,每个batch计算的梯度更接近全局梯度,但更新频率降低,如果此时学习率不变,相当于步子过大,走动范围也跟着放大。解决经验是做一个“线性缩放”:batch_size翻倍,学习率可以先尝试翻倍;batch_size减半,学习率也减半。这不是绝对定理,但作为起步规则很有效。我在切换batch配置时,总是同时记录两条曲线,一条loss一条准确率,这样可以快速判断到底是学习率过大还是batch内部数据分布有问题。
5.5 实验结果不稳定,隔一次跑结果差不少
现象:同一个脚本,连续跑两次,测试准确率相差2%以上,但又说不出代码改了哪里。原因主要是权重初始化和数据shuffle都依赖随机性。解决方法是固定随机种子:在import numpy之后立即np.random.seed(42),并在训练脚本里保持同一个seed;汇报算法结论时,不要只跑一次报结果,而是换不同seed跑3到5次,报告均值和方差。这样既保证你自己的实验可复现,也让别人在复现你的结论时不会因为随机性产生怀疑。
6. 验证手写全连接网络的正确性:梯度检查、对照试验与下一步
6.1 梯度检查:用有限差分验证你的反向传播没写错
手写反向传播最容易出现“能跑但逻辑错”的情况,loss在降,实际走的是歪路。这时候最靠谱的验证手段是梯度检查:对每个参数W,分别加上和减去一个很小的epsilon,算出损失函数在这两个点的斜率估计值,再和你backward里算出的解析梯度对比。如果两者相对误差在1e-4以内,反向传播基本没问题;如果误差在1e-2以上,那一定哪里写错了。
eps = 1e-6 w = params['W1'] numeric_grad = np.zeros_like(w) for i in range(w.shape[0]): for j in range(w.shape[1]): w[i, j] += eps loss_plus = compute_loss(forward(X_batch, params)[0], y_batch) w[i, j] -= 2 * eps loss_minus = compute_loss(forward(X_batch, params)[0], y_batch) w[i, j] += eps numeric_grad[i, j] = (loss_plus - loss_minus) / (2 * eps)逻辑说明:compute_loss这里对应交叉熵损失函数。梯度检查只需要在一个batch上做,不要在整个数据集上跑,否则太慢。参数说明:epsilon取1e-6比较合适,太小会出现浮点数精度问题,太大则数值导数偏差明显。这个验证步骤就是手写网络最好的“后悔药”,我每次写完backward都会先跑一遍梯度检查,再上数据训练。
6.2 用sklearn的MLPClassifier做对照:你的网络到底达到什么水平
做完梯度检查,你还需要一个外部基准。常见做法是拿sklearn的MLPClassifier,配相同结构的隐藏层,在同一个训练测试划分上跑一遍,再把准确率和你的NumPy版本对比。sklearn里默认的solver是adam,手写网络用的是SGD,所以两者的收敛速度本来就会有差异,别指望准确率完全一致。如果手写版本在测试集上只差3到5个百分点,说明逻辑没有问题;如果差距大到离谱,回头检查第5章里的五个问题。
我一般会把这种对照当作日常开发习惯:先手写,跑通,再调框架。有了框架的结果,你就能知道自己手写代码的边界在哪里,也不会因为一个不收敛的loss而怀疑人生。
6.3 从这里走向实战:动量、学习率衰减和PyTorch的迁移
当你的手写全连接网络已经稳定跑到90%以上的准确率,下一步就是给它加一点实战能力。最常见也最有效的两个改进是动量和学习率衰减。动量让参数更新带上惯性,能有效抑制震荡;学习率衰减让训练后期步子越来越小,方便收敛到更优解。更新公式从一行变两行:先记录velocity,再更新权重。
# 动量SGD更新,momentum一般取0.9 v[key] = momentum * v[key] - lr * grads[key] params[key] += v[key]如果你想把同样的网络迁到PyTorch,逻辑完全一致:nn.Linear对应全连接层,F.relu对应激活函数,backward由自动求导完成,你只需要关心前向传播写对。到这一步,手写网络这段经历就成了你理解PyTorch源码和调试自训练脚本的真正底子。我自己后来做项目时,也依然保留着“先梯度检查、再对照基准、最后调参”的习惯,这套流程在手写阶段救过我太多次了,希望帮到你。
本文还有配套的精品资源,点击获取