ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

手搓BP神经网络预测雾霾:学习率、初始化与正则化实战复盘

手搓BP神经网络预测雾霾:学习率、初始化与正则化实战复盘 写这个项目的时候我手边正好有一批去年冬天的空气质量数据。每天看着“轻度污染”“中度污染”的预报我一直在想能不能自己动手做一个能提前一天预测雾霾的小模型。不需要多复杂只要能把明天的PM2.5浓度估个八九不离十就行。思来想去干脆不用现成的深度学习框架用纯Python加NumPy手搓一个神经网络。这个过程走下来最大的收获不是预测精度有多高而是原本那些对我来说模模糊糊的概念——学习率、参数初始化、正则化全都在调试loss曲线的过程中彻底搞明白了。这篇文章就当是一次完整复盘从数据准备讲到网络实现再到三个核心算法的拆解把我踩过的坑和调参的经验一并写出来。1. 项目背景与问题建模1.1 为什么选择手搓前馈神经网络雾霾预报本质上是一个回归问题。输入的是当天的气象条件和空气数据输出的是第二天的PM2.5浓度。神经网络在这里扮演的是一个“万能函数拟合器”的角色它能自动从历史数据里学到“什么样的天气组合容易导致污染累积”这类映射关系。选择前馈神经网络而不是LSTM这类循环网络是因为做“提前一天”的预测其实不需要特别强的时序建模能力。我们可以把过去N天的数据折叠成一个特征向量一次性喂给网络。这种“时间窗口折叠”的做法在气象、金融这类场景里非常常见它本质上是在用特征工程弥补模型结构的简单性。提到BP神经网络很多人可能觉得它是“老古董”但在这种中小规模数据集上一个结构清晰、能完全掌控的BP网络往往比直接套用复杂模型更实用。手搓而不是用PyTorch主要目的是为了看清楚每一个参数在训练过程中到底发生了什么变化。框架封装得太好梯度、权重、学习率这些概念反而容易变成黑盒。自己实现一遍前向传播、反向传播、参数更新之后再回去看任何深度学习框架的文档都会有“原来如此”的感觉。1.2 数据准备与特征选择我用的数据是某市环保监测站的历史记录包含以下字段PM2.5浓度当天的也是明天预测的基础PM10浓度SO₂、NO₂、CO、O₃浓度温度、湿度、气压、风速、风向原始数据是逐小时的我先按天做了平均得到每天一条记录。这里有一个很关键的细节预测明天必须只用“今天及以前”的数据否则就构成了数据泄露。也就是说输入向量的所有特征都是t时刻或者t-1、t-2时刻的值标签才是t1时刻的PM2.5浓度。我使用的特征窗口是3天具体格式如下特征编号含义1-6t日六项污染物浓度7-10t日四项气象参数11-16t-1日六项污染物浓度17-20t-1日四项气象参数21-26t-2日六项污染物浓度27-30t-2日四项气象参数这样每个样本的输入维度是30输出维度是1。数据集总共收集了两年约700天的数据按照8:2划分成训练集和测试集。在把数据喂给网络之前必须做标准化处理。我用的方法是Z-score标准化也就是对每个特征减去均值、除以标准差。这一步极其重要——如果不做标准化像CO这种数值在个位数级别的特征和PM2.5这种数值在几十到几百之间波动的特征混在一起神经网络的训练会变得异常缓慢甚至不收敛。1.3 网络结构设计网络结构采用经典的三层结构输入层30个神经元隐藏层12个神经元激活函数用tanh输出层1个神经元无激活函数回归任务隐藏层神经元数量为什么选12这是根据经验公式balance出来的。大致可以参考输入层和输出层神经元数量之和的平方根附近再往大了调。12这个数字在我的数据集上训练速度和精度比较均衡。也可以试试16、20但隐藏层过多在小数据集上容易过拟合后面正则化部分会重点聊这个问题。2. 核心细节从零实现BP网络2.1 手写前向传播与反向传播既然要“手搓”代码里就不能出现任何框架的自动求导。前向传播很好理解数据从输入层进来经过权重矩阵和激活函数一层层往外传。反向传播则要把输出端的误差一层层传回来用链式法则计算出每个权重的梯度。下面给出我用NumPy实现的核心部分这几乎是BP网络最精简的模板import numpy as np def tanh(x): return np.tanh(x) def tanh_deriv(x): return 1.0 - np.tanh(x) ** 2 class BPNet: def __init__(self, n_input, n_hidden, n_output): # 初始化权重和偏置 self.W1 np.random.randn(n_input, n_hidden) * np.sqrt(2.0 / n_input) self.b1 np.zeros(n_hidden) self.W2 np.random.randn(n_hidden, n_output) * np.sqrt(2.0 / n_hidden) self.b2 np.zeros(n_output) def forward(self, X): self.z1 np.dot(X, self.W1) self.b1 self.a1 tanh(self.z1) self.z2 np.dot(self.a1, self.W2) self.b2 return self.z2 def backward(self, X, y, lr): m X.shape[0] y y.reshape(-1, 1) # 输出层误差 dz2 self.z2 - y dW2 np.dot(self.a1.T, dz2) / m db2 np.sum(dz2, axis0) / m # 隐藏层误差 da1 np.dot(dz2, self.W2.T) dz1 da1 * tanh_deriv(self.z1) dW1 np.dot(X.T, dz1) / m db1 np.sum(dz1, axis0) / m # 参数更新 self.W2 - lr * dW2 self.b2 - lr * db2 self.W1 - lr * dW1 self.b1 - lr * db1注意最后两步参数更新的写法就是神经网络的学习过程。每一次迭代都在朝“让损失函数下降最快的方向”挪一小步这一小步的尺度就是学习率。2.2 损失函数与评估指标回归任务最常用的损失函数是均方误差MSE。对雾霾预测这个场景来说MSE对异常值比较敏感这意味着如果某天出现极端污染事件模型会被“拉着”去拟合那个尖峰反而影响了普通天的预测效果。但从实际效果来看MSE依然是最好训练、最稳定的选择所以我还是先用它。评估指标除了MSE之外我还关注平均绝对误差MAE因为MAE的单位和PM2.5浓度一致更容易向别人解释。“我们模型的平均误差是每立方米15微克”听起来就比“均方误差是380”直观得多。2.3 训练过程的全貌完整训练循环一般长这样把训练数据按小批量batch打乱每个batch喂给网络前向传播计算预测值计算损失反向传播求梯度用学习率更新权重跑完所有batch记为一个epoch每个epoch结束之后在验证集上评估一次刚开始我图省事用全量梯度下降也就是一次性把所有数据都喂进去。训练速度太慢而且容易陷入局部最优。后来改成小批量batch大小设为32收敛速度明显提高而且loss曲线更平滑。训练过程大概跑了2000个epoch才完全收敛这个数字听起来很多但因为网络结构很小实际运行时间只有几百毫秒。3. 学习率那个让人又爱又恨的超参数3.1 学习率过大或过小的表现学习率决定了神经网络每一步参数更新的跨度。我用同一个模型、同一种初始化方式只修改学习率得到的结果差异非常大学习率训练集MSE测试集MAE表现0.001未充分收敛34.2训练极慢loss几乎不下降0.0121018.6收敛正常精度尚可0.115615.3收敛很快测试集表现不错0.5出现震荡29.8loss上下剧烈波动难以稳定1.0发散到NaN无法预测权重爆炸彻底失败当学习率太小的时候loss下降得跟蜗牛爬一样训练几千个epoch仍然欠拟合。这种情况下模型有一种“永远学不会”的错觉但实际上只是步子迈得太小。当学习率太大的时候参数更新的步长会跨过最优点甚至一步一步跳到损失函数的“悬崖”外面梯度不断累积最终导致权重变成NaN整个模型报废。我在调试时最喜欢用的一个技巧是先用一个较大的学习率比如0.1去试探如果loss在第一个epoch内就发散那就依次除以10往下缩。如果loss能正常下降但速度偏慢再适当放大。这个过程就像拧水龙头先开到最大再慢慢往回调。3.2 学习率调度器的必要性固定学习率有一个天生缺陷训练前期权重距离最优解很远需要大步伐快速接近训练后期权重已经很接近最优解了这时候再用大步伐就容易在最优解附近来回震荡永远无法精确定位到最低点。解决办法就是学习率调度器。最简单的策略是阶梯式下降比如每500个epoch把学习率缩小到原来的0.5倍。我自己试过一种更顺滑的方式——余弦退火调度公式如下def cosine_annealing(epoch, total_epochs, lr_max, lr_min1e-5): return lr_min 0.5 * (lr_max - lr_min) * (1 np.cos(epoch / total_epochs * np.pi))用这个调度器之后我观察到训练前期的收敛速度和后期loss曲线在最低点附近的平稳度都有了明显改善。最终我的选择是初始学习率0.1配合余弦退火在2000个epoch里从0.1平滑降到接近0。这比固定学习率的效果好了大约5%的测试集MAE。3.3 动量与自适应方法的经验除了调度器在优化算法层面也有让学习率更高效的改进办法。动量法就是典型代表——它让参数的更新方向不仅依赖于当前梯度还部分参考了上一次更新的方向。类比来说学习率只管每次迈多大步而动量是在小球下山的时候给小球加一个“惯性”让它滚过小的坑洼更快冲向谷底。我测试了带动量的SGD和Adam优化器。观察到亚当优化器在雾霾预测这个问题上基本不需要精细调学习率0.001就能跑得很好。但我的目的本来就包含教学所以我还是坚持把SGD和余弦退火作为主推方案——因为它的行为更可预测更容易解释每一次更新背后发生的事情。4. 初始化一个好的起点决定了终点4.1 全零初始化为什么不行如果所有权重都初始化为0会发生什么前向传播时每一层所有神经元接收到的输入是一样的输出的值也一样。反向传播时同一层内所有神经元的梯度完全相等更新之后权重依然相同。无论训练多少个epoch隐藏层的12个神经元都在做一模一样的事情——整个网络退化成只有一个神经元的线性模型表达力全部丧失。这就是为什么随机初始化是必须的。但它不是随便随机就可以的初始化方差的设置直接影响训练的成败。4.2 Xavier与He初始化的选择逻辑Xavier初始化也叫Glorot初始化和He初始化是目前最常用的两种方案。它们的核心逻辑都是让信号在层间传递时保持方差稳定不要越传越放大到发散也不要越传越缩小到消失。具体来说Xavier初始化适合tanh和sigmoid这类对称激活函数权重从均值为0、方差为2/(n_input n_output)的分布中采样He初始化适合ReLU及其变体方差为2/n_input我的隐藏层用的是tanh激活函数所以我最初用Xavier初始化。后来我试着把隐藏层换成ReLU发现必须搭配He初始化才能让训练稳定。这背后的原因是ReLU会把负半轴的梯度全部置零如果不把正向的方差适当放大信息经过几层传递之后就会淹没在噪声里。实际代码里我直接用了NumPy的randn乘以缩放系数# Xavier self.W1 np.random.randn(n_input, n_hidden) * np.sqrt(2.0 / (n_input n_hidden)) # He self.W1 np.random.randn(n_input, n_hidden) * np.sqrt(2.0 / n_input)这里想特别提醒的是偏置bias的初始化。很多人容易忽略它我自己的习惯是全部初始化为0这样配合随机初始化的权重是没问题的。但如果你把偏置也设成很大的随机值那就相当于在每一层都施加了一个直流偏置信号会严重阻碍前期的收敛。4.3 我的一次踩坑经历有次我贪省事直接用np.random.randn初始化权重标准差是1。当时没觉得有什么问题结果loss曲线在一个很小的值附近徘徊了特别久。我把学习率从0.01一路调到0.5还是没多大反应。后来打印出来的权重分布一看初始权重太大经过tanh之后输出全部饱和在±1附近梯度几乎完全消失。换成Xavier初始化之后loss在同一个学习率下立刻开始正常下降。这件事给我的教训非常直接遇到模型“不学习”的情况先检查初始化再看数据标准化最后才调学习率。顺序错了花再多时间也是白费。5. 正则化对付过拟合的关键武器5.1 过拟合在雾霾预测里的具体表现雾霾数据本身就带有很强的季节性特征冬天的污染水平天然高于夏天。如果模型把“冬天”这个标签记住而不是学会“污染物逐渐累积”这个规律就会在测试集上表现很差。具体表现是训练集loss降得很低但测试集loss连续多个epoch不降反升。为了进一步放大过拟合现象我还专门做了一组对比实验把隐藏层从12个神经元增加到64个神经元去掉所有正则化手段只训练300个epoch。训练集MSE降到了30左右但测试集MAE反而比12个神经元的模型还要差。这就是典型的“记住了训练数据却没学会规律”。5.2 L1正则化与L2正则化的本质区别正则化的思路是在损失函数后面加上一个惩罚项约束权重不要变得太大。L1正则化和L2正则化的惩罚方式不同产生的效果也不同。L2正则化也叫权重衰减的惩罚项是所有权重的平方和对应公式为loss mse_loss lambda * np.sum(W1 ** 2) lambda * np.sum(W2 ** 2)它对大权重的惩罚是二次的所以权重越大被压制得越狠。结果就是模型里每个特征的贡献都被“平均化”了权重整体变小且分布比较平滑。L2正则化解决的是“模型过于自信”的问题让预测不那么容易被某个单特征左右。在水质、空气质量这类多种因素共同作用的问题上L2正则化效果非常理想。L1正则化的惩罚项是所有权重绝对值之和对应的特点是会把一部分权重直接压到0。它天然具备特征选择的作用——某些无关紧要的输入特征会被网络主动“遗忘”。在雾霾预测这个场景里如果我加入了很多毫无意义的特征比如当天的股市指数L1正则化可能就会让这些输入对应的权重变成0。有个经典结论是“软阈值算子是L1正则化的解”翻译成白话就是L1正则化会让权重在做梯度更新的时候经历一个“先缩到0、一部分彻底清零”的过程。如果数据集特征很多很多特征是噪声的话L1会比L2更好用。但考虑到我的特征都是经过挑选的、和空气质量强相关的指标L2正则化更多时候是我的首选。下面是同样网络、同样数据下加正则化和不加正则化的对比模型训练集MSE测试集MAE隐藏层64节点无正则化3821.7隐藏层64节点L2λ0.00114015.9隐藏层64节点L2λ0.0120514.8隐藏层12节点L2λ0.0119015.1可以看到加了L2正则化之后虽然训练集上的拟合效果变差了但测试集MAE明显下降。这个“用训练精度换泛化能力”的权衡正是正则化存在的意义。5.3 Dropout与早停的实战对比对全连接神经网络来说Dropout也是个非常有效的正则化手段。Dropout的原理是在训练过程中随机丢弃一部分神经元强迫网络学会“分布式编码”不能把全部希望寄托在某个单一神经元上。我在手写代码里实现Dropout只加了两行# 训练时以keep_prob概率保留神经元 mask np.random.rand(*self.a1.shape) keep_prob self.a1 * mask self.a1 / keep_prob # 保证期望值不变注意后面那步要除以keep_prob这叫“反向缩放”。如果不做这一步网络在训练时和推断时输出的数值范围会不一致导致测试效果大幅缩水。早停法算是零成本的正则化。做法是在每个epoch结束之后检查验证集loss如果连续20个epoch都没有下降就提前终止训练并恢复最优权重。这个策略非常适合雾霾预报这种场景——数据量本身不大过度训练的风险很高早停能有效控制训练时长。有意思的是如果把早停和L2正则化叠加在一起反而没有各自单独使用效果好。我猜测原因是两种正则化机制的作用存在部分重叠叠加之后会过度压制模型的表达能力。所以我的建议是小规模BP网络优先用早停如果测试集泛化误差还是不满意再加入较小的L2正则项不要一开始就一顿操作全怼上去。5.4 正则化系数怎么定正则化系数λ的取值是个很头疼的问题。太大模型变得过于简单欠拟合太小正则化不起作用。我采用的是“对数网格搜索”策略先把λ按数量级试0.001、0.003、0.01、0.03、0.1观察训练集和验证集loss的差距如果训练集和验证集loss都高说明λ太大了如果训练集低但验证集高说明λ太小了在表现最好的两个数量级之间继续二分最终在这个项目里L2系数定为0.01效果最好。不过也要提醒一句这个系数高度依赖数据规模和特征标准化方式直接抄作业不一定有效务必重新调试。6. 实验过程与结果分析6.1 完整训练配置速查表这里把最终成功跑通的配置罗列出来方便复现配置项最终选择网络结构30-12-1激活函数隐藏层tanh输出层linear损失函数MSE优化器SGD with Momentummomentum0.9初始学习率0.1学习率调度余弦退火2000个epoch降到1e-5初始化Xavier正则化L2λ0.01Batch大小32早停验证集连续40个epoch不改善则停止这个组合在测试集上得到的MAE约为14.6μg/m³。对一个只用了一公里之外气象站数据、没有加入任何卫星遥感或污染源排放清单的简易模型来说这个精度已经达到了我预定的目标。6.2 预测效果解读从结果看模型对重度污染天气的预测能力弱于轻度污染。后来我分析了误差最大的几个样本全部是PM2.5浓度超过200μg/m³的重度霾日。深层原因是训练集中这样的极端样本占比非常小模型根本没有足够的样本去学习“极端污染是如何形成的”。解决这种问题有三个方向一是收集更长时间跨度的数据尤其是包含更多极端污染事件的年份二是采用加权损失函数给极端样本更高的权重三是把回归问题转化为多分类问题比如把污染等级分为优、良、轻度、中度、重度五档分类模型对极端类别的召回率通常会比回归模型高一些。6.3 特征重要性初探虽然BP神经网络的权重解释性不强但我可以通过一个简单粗暴的方法观察哪些特征更重要每次把某个特征的所有值都替换为该列均值重新预测计算MSE的变化量。MSE上升幅度越大说明这个特征对预测的贡献越大。实测结果中最重要的是当天的PM2.5浓度和气压。这个结论非常合理——污染物浓度本身具有惯性今天的空气质量对明天有很强的指示作用而气压高低决定了大气扩散条件的好坏。温度的作用相对较弱风速和湿度则介于中间。7. 常见问题排查与避坑指南7.1 问题速查表现象最可能原因排查顺序loss为NaN学习率过大从0.001开始重调检查数据是否有无穷值loss完全不下降数据未标准化检查特征均值方差训练好但测试极差过拟合加正则化、减小网络规模、早停收敛极慢初始化不当换成Xavier或He初始化loss先下降后猛涨学习率调度失效检查是否有梯度爆炸考虑梯度裁剪预测总是接近一个固定值输出层无激活函数但标签范围过大检查标签是否标准化7.2 数据泄露是最大的隐性坑在整理时间序列数据时最隐蔽的错误是误把未来数据当特征。比如用当天全天的平均PM2.5去预测“当晚零点之后”的PM2.5这个信息事实上属于同一个污染过程模型自然“预测”得很准但一上线就完蛋。我的判断原则是训练集里出现的所有信息在真实推理时必须是“当时已经可以获得的”。天气也一样如果你用的是“明天实测的天气数据”去预测“明天的空气质量”这在部署时是不可行的因为在提前一天做预测的时候明天实测数据还没有产生。正确的做法是用天气预报数值或者干脆只用历史序列特征。7.3 手搓网络时的几个额外建议最后分享一个我反复踩过坑的点每写一层新的实现都要先用一个极小的数据集比如10个样本做一次“过拟合测试”。如果模型连10个样本都学不会说明代码里大概率有bug。等这个测试通过了再逐步扩大数据量。这个习惯帮我节省了大量排查问题的时间。另外一个建议是把训练过程中的每种配置都记录下来。我用一个字典保存了学习率、初始化方式、正则化系数、batch大小以及对应的训练和测试指标。模型调试本质上是在高维空间里搜索最优解没有日志回溯前面试过的所有配置都等于白试。整个项目做下来最大的体会是神经网络的三个核心超参数——学习率、初始化、正则化——并不是孤立发挥作用的。好的初始化让模型有了一个好的起点合适的学习率让模型每一步都走得稳正则化防止模型在终点附近走火入魔。三者缺一不可。后续如果有时间我打算把模型改成多输出同时预测明天和后天的PM2.5浓度或者在输入端加入未来24小时的模式预报结果看看预测精度还能往上走多少。
返回列表