1. 从经典作业聊聊:为什么几乎所有初学者都要写一次PM2.5预测
聊到机器学习入门,台湾大学李宏毅老师的课程几乎是华语圈的“现象级”资源。而这门课的作业一——用回归模型预测PM2.5,我敢说每一个认真跟完课程的人都在这道题上熬过几个晚上。它看起来特别简单:给你过去9个小时的观测数据,预测第10个小时的PM2.5浓度。但就是这么一道“入门题”,里面埋着数据预处理、特征工程、梯度下降、学习率调整、正则化,甚至还有对过拟合的初步感知。我当年做完这道题之后最大的感受是:原来学校里教的数学公式,真正落到数据上,每一步都可能是坑。
这篇文章就以这个经典作业为蓝本,完整复盘一遍PM2.5预测实例从数据处理到模型训练、再到调优验证的全过程。不绕弯子,直接讲每一步怎么做、为什么这么做、踩过的坑长什么样。如果你正在跟李宏毅老师的课,或者想找一个最小可运行的回归实战项目练手,这篇文章基本能让你少走一半弯路。
1.1 核心需求解析:这到底是个什么问题
先把这个题目翻译成人话。作业给的数据集是一年365天、每天24小时的空气质量观测记录,每小时会记录18项指标,包括PM2.5、PM10、SO2、NO2、CO、O3,还有温度、湿度、风速、降雨量等等。数据本身是CSV格式,每行代表某个观测站某一天某个时刻的完整指标快照。作业的核心任务是:用前9个小时的所有观测数据,预测第10个小时的PM2.5浓度值。
这里有个关键点要拎清楚——这是一个典型的回归问题,不是分类。我们要预测的是一个连续数值,而不是“空气好/空气差”这种离散标签。所以输出层的激活函数用不上sigmoid那一套,损失函数也不能用交叉熵,而是用均方误差(MSE)这类衡量“预测值与真实值距离”的指标。李宏毅老师的课里会用这个例子反复强调:回归和分类在数学框架上同源,但因为输出的性质不同,整个训练目标都会跟着变。
另一个容易忽略的点是:作业虽然在课程早期出现,但它其实要求你手写梯度下降,而不是直接调sklearn或者PyTorch一把梭。目的很简单——逼你在代码层面理解“参数是怎么被更新”的。如果你直接用框架一行搞定,那么学习率、梯度消失、特征缩放这些问题,你永远只是“听说过”,而不是“真正见过”。
1.2 数据观察:训练集与测试集划分的潜台词
作业提供的原始数据里,前一部分是完整的训练数据,后一部分是测试数据。但测试数据的标签是被隐藏的——也就是说,你只能看到同样格式的18项指标,但最后一列PM2.5的真实浓度没有提供。你需要把模型的预测结果提交到课程平台上,由平台给你打分。
这种设定非常接近真实世界的机器学习流程:你手上的永远只有部分有标签的数据,模型要面对的是未知的、没有标签的新数据。所以你在训练时不能自己“偷看”测试集标签来调参,否则就是在自欺欺人。正确的做法是把训练数据再切出一部分作为验证集,用验证集的误差来指导调参,最后才让模型去预测测试集,生成提交文件。
我当时是这样做的:把训练数据按“天”为单位打乱,用前80%的天作为训练集,后20%的天作为验证集。注意这里是按天切分,而不是按行随机切分。因为同一小时内18项指标是有内在相关性的,如果把同一时刻的不同指标拆到训练集和验证集,会造成信息泄漏,验证结果会虚高。这个细节虽然不深,但能帮你早早建立“数据切分不是随便切切”的意识。
2. 手把手拆解:数据预处理与特征工程才是这个项目的重头戏
很多人做这个作业,一上来就急着搭模型,结果代码跑完发现loss居高不下,或者收敛得慢得离谱。我自己的经验是:这个项目里,数据预处理和特征工程的优先级远高于模型结构。因为线性回归模型的表达能力本来就有限,你喂给它的特征质量基本上决定了性能天花板。李宏毅老师在课上把这个例子作为“回归入门”来讲解,但他其实默认你已经能把数据处理好。
2.1 原始数据的坑:缺测值与无效值处理
原始数据里有一个很烦人的设定——缺测值用-1或者NR来表示,NR一般出现在降雨量那一列,意思是No Rain,即无降雨。虽然NR在语义上是“无降雨”,但如果直接把它当作数值参与运算,模型就会认为降雨量是-1,这完全不符合物理含义。所以第一步必须做数据清洗:把所有NR字符串替换成数值0,把所有-1之类的缺测标记处理成NaN,再决定是补零、取均值还是直接丢弃。
这里有个所有教程都不会细说、但你在真实项目中一定会碰到的教训:对缺测值最简单的处理不一定是“最正确”的处理。比如PM2.5浓度如果出现-1,补零显然不合理,因为浓度不可能是负数;取前后时刻的平均值则相对合理,因为污染物浓度的变化是连续的。对于这个作业,我的做法比较保守:先统计每个特征维度的缺测比例。如果缺测比例很小(小于1%),直接丢弃对应行;如果缺测集中在某一列,就对该列做线性插值。实操下来,这个策略对最终分数的影响不大,因为数据本身质量比较高,但面对真实数据集时,这一步往往会直接决定成败。
2.2 特征窗口设计:为什么用9个小时,而不是更长或更短
这个作业最核心的建模决策是:用过去9个小时的观测数据预测下一个小时。为什么是9?因为题目就是这么出的,但这个设定在数学上并非唯一选择。在我自己复现时,我尝试过用6小时、12小时、24小时的窗口长度做对比实验,结果还挺有意思:窗口越长,模型的表现反而没有显著提升,甚至略有下降。原因很直观——PM2.5的浓度在短时间内受气象条件、排放源等因素影响,存在一定的“记忆效应”,但是这种记忆并不是无限延长的。过去24小时的平均浓度确实有影响,但如果你把它拆成24个单独的输入特征,线性模型反而很难从高维稀疏的输入中学到有效规律,因为特征维度增加了,样本量没变,过拟合的风险就上来了。
所以这里有一个通用的特征工程思路:与其盲目堆更多小时的数据,不如把已有数据加工成更有信息量的特征。比如原始作业只给每个时刻的18项指标,但你可以自己构造一些衍生特征,比如“过去9小时PM2.5的均值”“过去9小时的最大值”“过去3小时的趋势斜率”。我在训练中发现,加上这几列衍生特征之后,验证集上的均方误差大约降低了5%到8%。这在Kaggle竞赛里可能不算什么,但作为作业已经能明显感觉到模型的“聪明”程度不一样了。
2.3 特征范围差异太大?先用标准化把一切拉回同一起跑线
原始数据里,不同指标的量纲差异非常大——PM2.5浓度可能只有几十,而CO浓度可能是几百甚至上千,温度和湿度又是另一种数量级。如果你直接把这样原始的数据喂给线性回归模型,梯度下降会非常痛苦,因为不同维度上的参数更新步长会被特征的尺度差异扭曲。有些维度已经收敛了,有些维度还在原地踏步。
解决办法就是特征标准化,最常用的是Z-score标准化,也就是对每个特征减去均值、除以标准差,让每个特征的分布大致落在零附近、标准差为1。这样处理后,梯度下降的收敛速度会明显加快,而且能减少某些大数值特征对损失函数的主导。注意,这里的均值和标准差只能从训练数据中计算,然后用于训练集和测试集的转换,绝不能用测试集的数据来计算均值和标准差,否则就是信息泄漏,这几乎是所有机器学习比赛里最不能犯的错之一。
3. 模型选型与训练细节:线性回归背后的数学和工程
数据处理好之后,就进入模型搭建环节了。李宏毅老师的作业版本里,模型就是一个再简单不过的线性回归:y = wx + b。这里w是权重向量,x是输入特征向量,b是偏置项。在实现上,你可以把b合并到w里,做法是在x向量的末尾拼接一个常量1,这样整个模型就写成y = w^T x,代码更整齐,计算也更方便。
3.1 手写梯度下降:从公式推导到代码落地
线性回归的损失函数是均方误差,模型要做的就是找到一组w,让所有训练样本上的平均平方误差最小。对损失函数求梯度之后,你会发现一个非常漂亮的结果:梯度只和输入特征、预测误差有关。这意味着你不需要任何复杂的反向传播库,只需要用numpy就能手写完整的训练循环。代码如下:
import numpy as np import pandas as pd # 假设 X_train 是已经做过标准化的特征矩阵,形状为 (N, D) # y_train 是标签向量,形状为 (N, ) def train_linear_regression(X_train, y_train, learning_rate=0.01, epochs=1000): N, D = X_train.shape # 在最后一列拼接1,对应偏置项 X_train = np.hstack([X_train, np.ones((N, 1))]) w = np.zeros(D + 1) for epoch in range(epochs): y_pred = np.dot(X_train, w) loss = np.mean((y_pred - y_train) ** 2) grad = np.dot(X_train.T, (y_pred - y_train)) / N w = w - learning_rate * grad if epoch % 100 == 0: print(f"Epoch {epoch}, Loss: {loss:.4f}") return w这段代码就是核心了。别小看它,我见过不少同学在这个地方卡住,原因不是公式不会推,而是矩阵形状对不上,导致numpy报错。我的建议是:每一步都打印出X_train的形状、w的形状、grad的形状,确保它们在矩阵乘法时能对上。一旦跑通,理解“梯度下降就是在梯度的反方向更新参数”这句话就不再是空话了。
3.2 学习率怎么定:太大loss爆炸,太小收敛到天亮
手写梯度下降之后,你马上会面临一个非常现实的问题:学习率到底设多少?学习率太大,loss会剧烈震荡,甚至发散到NaN;学习率太小,模型要跑几千轮才能收敛,浪费时间。李宏毅老师的课里专门提到这个问题,并且介绍了一个很实用的策略——自适应学习率Adagrad。简单说,Adagrad会根据每个参数历史梯度的平方和来动态调整该参数的学习率:梯度大的方向步长变小,梯度小的方向步长变大。这比手动调一个全局学习率要省心得多。我当时用普通梯度下降配合固定学习率跑到5000轮,loss还在缓慢下降;切换成Adagrad之后,几百轮就收敛了。
Adagrad的实现也不复杂,核心思想是维护一个梯度平方的累积变量,然后每次更新时除以累积量的平方根。不过要注意的是,累积量会不断增大,导致学习率越来越小,所以通常会在分母上加一个很小的平滑项,防止除以零。代码如下:
def train_adagrad(X_train, y_train, learning_rate=1.0, epochs=1000): N, D = X_train.shape X_train = np.hstack([X_train, np.ones((N, 1))]) w = np.zeros(D + 1) grad_square_sum = np.zeros(D + 1) eps = 1e-8 for epoch in range(epochs): y_pred = np.dot(X_train, w) grad = np.dot(X_train.T, (y_pred - y_train)) / N grad_square_sum += grad ** 2 w = w - learning_rate / np.sqrt(grad_square_sum + eps) * grad if epoch % 100 == 0: loss = np.mean((y_pred - y_train) ** 2) print(f"Epoch {epoch}, Loss: {loss:.4f}") return w如果你使用PyTorch或者TensorFlow,不需要自己实现Adagrad,直接用内置的优化器就行。但通过手写一次,你能深刻理解那些优化器内部到底发生了什么。
3.3 正则化:绕不开的话题,也是拿分关键
纯线性回归在这个作业上,训练集loss会很低,但验证集上的表现往往一般。为什么?因为特征维度并不低(18个指标乘以9个小时,再算上衍生特征,轻松超过100个维度),而有效的训练样本量并没有想象中那么多。这种情况下,模型很容易过拟合——它记住了训练数据里的噪声,而不是捕捉普遍的规律。
解决过拟合的标准手段是加正则化项。最常见的是L2正则化(岭回归)/L1正则化(Lasso),李宏毅老师在这个作业中推荐的版本会在损失函数里增加权重的平方和项。加了正则化之后,模型会倾向于把权重压缩到较小的数值,从而降低对单个特征的敏感度。实际操作时,你需要额外设置一个正则化系数lambda,lambda太小起不到约束作用,lambda太大则会欠拟合。我自己的经验是先给lambda设置为0.001,观察验证集loss的变化,再逐步调大或调小。
很多同学在这一步容易犯的毛病是:把正则化系数和梯度下降的学习率搞混。这两个是完全不同的超参数——学习率控制的是更新步长,正则化系数控制的是权重的大小约束。我在调试时习惯把学习率和正则化系数分开调整:先固定正则化系数为0,找到一个合适的学习率区间,然后再开启正则化,搜索合适的lambda值。
4. 训练验证与调优:在分数之外的收获
训练过程跑通之后,很多人就急着提交了。但如果你愿意多花半小时做点验证和调优,你会发现这个项目的收益会远大于“跑出一个预测结果”本身。因为真正的机器学习项目,几乎永远是“训练-验证-分析-再训练”的循环,而不是一次到底的流水线。
4.1 验证集上的误差分析:不只是看一个数字
只盯着验证集上的平均loss,是看不出模型哪里做得不好的。更有效的做法是把预测值和真实值画在一张散点图里,查看误差的分布情况。我当时的做法是计算每个样本的绝对误差,按误差大小排序,然后看看误差最大的那些样本有什么共性。结果很有意思:误差最大的样本基本集中在PM2.5浓度剧烈变化的时刻,比如清晨的峰值、沙尘暴事件的突发时段。这说明线性回归模型擅长描绘趋势性的变化,但很难捕捉突变性的事件。
这个发现有什么用?它能指导你做两个方向的改进:一是为模型添加“突变提示”特征,比如过去1小时和过去3小时PM2.5浓度的一阶差分,这个特征在线性模型里可以变相模拟出“对趋势变化的响应”;二是将训练数据按时间段分组建模,采用分段回归的思路,比如高峰期和非高峰期分别训练一个子模型,再融合结果。我当时做了第一个方向的尝试,验证集loss又下降了一些。这也算是从“完成作业”迈向“理解模型能力边界”的第一步。
4.2 使用现成框架重写:一次对比,彻底搞懂框架帮你做了什么
手动实现一遍之后,强烈建议再用PyTorch或者sklearn把同一个模型重写一遍。这不是无用功,而是帮你建立“数学公式/代码实现/框架API”三者之间的映射关系。用PyTorch写这个任务,核心代码量也就二三十行,但你可以清晰看到optimizer.zero_grad()、loss.backward()、optimizer.step()这三个步骤,恰好对应着手写版本里的前向计算、梯度计算、参数更新。
我记得第一次用PyTorch重写时,最不适应的是它默认的权重初始化方式。手写版本我用的全是零初始化,而PyTorch默认用均匀分布或正态分布初始化。理论上,只要数据标准化做得好,两者都能收敛到差不多的结果,但在某些随机种子下,线性回归的训练曲线会有一点差异。这个经验让我意识到:在深度学习框架中,随机初始化本身就是一个值得关注的超参数来源,只是在这个简单作业中它影响不大。
4.3 常见问题速查表:这里整理了我遇到过的所有坑
以下表格整理了我自己以及周边同学在这个作业中最常遇到的问题、可能的原因和解决方法。建议先收藏,遇到问题再回来对照查表。
| 问题现象 | 可能原因 | 解决方法 |
|---|---|---|
| 训练几轮后loss变成NaN | 学习率过大,梯度更新步长导致数值溢出 | 降低学习率,或将输入特征做标准化 |
| 训练loss很低,验证loss很高 | 模型过拟合,特征维度过多、样本不足 | 增加L2正则化,或减少特征维度 |
| 训练loss始终不下降 | 特征未标准化,梯度更新方向被大数值特征带偏 | 对特征做Z-score标准化,或用Adagrad |
| 验证集loss在训练中不断波动 | 学习率偏大导致参数在最优值附近震荡 | 降低学习率,或者使用学习率衰减策略 |
| 预测结果全部接近某个固定值 | 输出特征尺度过小,模型倾向于预测平均值附近 | 检查标签是否标准化,调整损失函数权重 |
| 用验证集调参后测试集分数仍不高 | 验证集切分方式不当,或验证集与测试集分布不同 | 按时间切片切分验证集,避免随机打乱带来的信息泄漏 |
| 程序报维度不匹配错误 | 拼接偏置项时维度没对齐 | 逐行打印X_train.shape、w.shape,检查np.hstack |
这里面最隐蔽的是“验证集切分方式不当”这一条。如果验证集是随机从所有天里抽出来的,那么训练集和验证集里会同时出现同一天的相邻时刻数据,造成信息重叠,验证集loss会比真实测试集表现乐观。对于时间序列类数据,切分时一定不能随机打乱,直接按时间先后切分才是正确的做法,这一点在工程实践中几乎天天遇到。
5. 关于这个作业的延展思考:线性回归是终点,也是起点
做完这个PM2.5预测作业,回头看它的设计价值,我觉得它最大的贡献不是教你线性回归公式,而是逼你先成为一个“数据工程师”,再成为一个“模型工程师”。你在预处理阶段处理的缺测值、构造的特征、标准化方式,在真实业务中占比可能超过70%的工作量。而模型那一小块——无论是线性回归还是后来的深度学习模型,反而是最容易被替代的部分。
这也是为什么我建议你即使已经用PyTorch能轻松调出各种模型,也值得抽时间把这个作业手写一遍。因为只有当你亲手实现过一次梯度下降,看到loss从几百一路降到几十、再降到十几的过程,你才会真正理解深度学习框架里那一个个抽象的API到底在做什么。之后再去看Adam、RMSProp这些优化器的论文,你会有一种“原来如此”的顿悟感,而不是被一堆数学符号劝退。
我自己在实际操作中还有一个体会:不要只停留在复现作业的层面,可以试试给这个项目加一点自己的改动,比如把线性模型换成决策树,或者试试支持向量回归,然后对比哪种模型在这个任务上表现更好。当你开始做对比实验的那一刻,你就已经从“跟着教程跑代码”进阶到“带着问题做研究”了。这个转变,才是这道作业真正想教给你的东西。