好几年前我第一次刷机器学习公开课的时候,被虐到的第一道动手题,就是“用线性回归预测PM2.5”。那会儿我以为线性回归就是一条直线套数据,结果打开课程配套的观测站数据才发现,事情完全不是这么回事——几十万行逐小时数据、18项空气指标、缺失值、雨天里写着的NR……光数据预处理就能折腾掉一半时间。后来工作里做时序预测、做回归类需求,回头再看这个作业,反而觉得它把很多基本功都揉进去了。
这篇就把我当时完整的思路和最终能跑通的方案写下来,包含从数据清洗、特征构造、标准化到梯度下降的推导和手写实现,以及我踩过的坑和排查过程。适合刚接触机器学习、正准备做这类回归作业的朋友,也适合那些想快速上手套模板但不知道每一步在干什么的初学者。你不需要先懂很多数学,跟着做一遍,踩一遍坑,比我讲十遍原理都管用。
1. 先看作业到底让你干嘛:问题拆解与选型思路
1.1 这题的本质是什么
作业目标一句话:给定一段时间的空气指标观测数据,用前9个小时的所有指标,预测第10个小时的PM2.5浓度。注意,这里不是单独用PM2.5的历史值去预测未来PM2.5,而是把当天的18种指标全部用上,包括PM10、NO、NO2、SO2、O3、风速、雨量等等。这件事的本质是一个多变量回归任务:输入是高维特征向量,输出是一个连续数值。
之所以选线性回归,不是因为它最厉害,而是因为它能让你把整个机器学习的流程走通:数据预处理、模型定义、损失函数、梯度下降、评估指标。这些环节一旦换到更复杂的模型上,你会因为不知道问题出在数据还是出在模型而寸步难行。先拿线性模型把流水线跑通,是这门课最合理的节奏。
我建议你拿到作业的第一步不是写代码,而是先把数据格式读明白。课程给的数据是一个CSV,每一行是某个观测站某一天的记录,18个指标横向排开,每个指标对应24个小时的观测值,所以一行有18×24这么多个数值,但结构上是把一天内所有小时的数据压成了行。很多新手一上来就按“一行一个样本”去理解,后面构造训练集的时候就会越绕越晕。
1.2 输入输出定义与样本构造方式
要构造训练样本,得先搞清“历史9小时”和“预测下1小时”的关系。通常的做法是:把每天的数据拆成连续的小时序列,然后设定一个滑动窗口。窗口长度是9小时,窗口内每个时刻都包含18个指标,所以一条样本的特征维度是9乘以18,也就是162个特征。窗口滑动的目标是预测窗口结束后的下一个小时,输出维度是1。
这里有个容易忽略的细节:一天只有24小时,如果窗口长度为9,那么一天内最多能产生24减9等于15个训练样本(窗口起点从0到14,起点到8共覆盖9小时,预测第9小时;按小时索引算还要根据具体切片方式调整)。所以一份看似庞大的数据,真正能构造出的样本量,比你想象中少很多。我当时算完差点以为自己漏了文件。
还有一点值得提前说清楚:训练集和验证集切分。很多人图省事,直接用随机切分,这在回归作业里不算大错,但对时间序列数据来说是不严谨的。PM2.5的浓度在时间上有强自相关,今天的浓度和昨天的浓度高度相关。如果随机打乱,训练集里混进了“未来”的数据,验证集里也有“过去”的数据,模型等于提前偷看了答案,验证误差会虚低。等换到真实预测场景,模型就原形毕露。正确做法是按时间顺序切分:靠前的时间段训练,靠后的时间段验证。
1.3 评估指标为什么选RMSE
作业一般要求用RMSE(均方根误差)作为评估指标。公式不复杂:把每个样本的预测值和真实值相减,求平方,加总求平均,再开根号。RMSE的单位和PM2.5浓度一样,含义直观,比如RMSE等于20,意思是平均下来预测值和真实值大约偏差20微克每立方米。这个指标对大的偏差惩罚更重,因为误差被平方了,所以如果模型在某些极端污染天预测偏移很大,RMSE会很难看。
我在做完第一版之后,还会额外看一个MAE(平均绝对误差)。它不像RMSE那样放大离群点,能帮你看清楚“常规情况下到底偏多少”。如果RMSE远大于MAE,说明你的误差主要集中在少数极端样本上,比如重污染时段或数据异常时段,这时候要排查的往往不是模型,而是特定天气数据。
2. 数据预处理:坑比模型本身多得多
2.1 数据里那些不讲道理的标记符号
打开原始CSV,你可能第一反应是“这数据怎么这么脏”。我当年碰到的最典型的问题是雨量字段里出现了NR,不是数字,也没有单位。如果你直接pandas读进来,这一列会被当成字符串,整列数值运算全部报错。
NR的意思是“无雨量记录”,在特定语境下可以当0看待,也可以当作缺失值处理。我当时的做法是把NR统一替换成一个特殊数值,然后单独加一个布尔特征“是否异常标记”。不过后来简化了一下,因为线性回归对异常标记的特征其实挺敏感的,加一个0/1特征反而容易学到一些奇怪的相关性,所以最后只是把NR替换成0。在处理自己的数据时,你要想清楚“这个符号到底代表无记录还是缺记录”,两种处理方式完全不一样。
缺失值方面,观测数据偶尔会出现NaN。最简单的做法是直接删掉那一行,如果缺失比例不高,影响不大。用全局均值或0去填充要看情况:PM2.5的日变化是U形,早晚高、午后低,如果某个小时缺失,直接填0会把样本特征拉向一个不存在的极端;填均值也会模糊掉该小时应有的周期性。比较可用的小技巧是用相邻小时的均值去填,或者干脆把缺失严重的采样点排除。我对比过,用相邻均值填充比用全局均值填充,最后验证集RMSE低了大概1到2个单位,效果肉眼可见。
2.2 标准化的必要性:不是可选项,是必选项
线性回归里如果特征不做标准化,梯度下降很容易像无头苍蝇一样乱撞。原因是不同特征的量纲差异过大:PM2.5浓度在几十到几百之间,风速是每秒几米,雨量可能是几毫米甚至0,而温度可能二三十摄氏度。这些数值放在同一个特征向量里,少数大数值特征会在梯度计算中占据主导地位,导致更新方向被带偏。
标准化的常用做法是z-score:对每个特征减去均值,再除以标准差。处理之后,每个特征大致变成均值为0、标准差为1的分布。这一步对线性回归有两个直接好处:一是梯度下降的收敛路径更平稳,二是特征的系数可以直接用来比较相对重要性。需要注意一点:标准化系数必须用训练集算出来,再应用到验证集和测试集上。绝对不能用全量数据先算均值标准差再切分,那会引入信息泄露,让验证集看起来过于理想。
我当时就是从“用整体数据标准化”这个坑里爬出来的:验证集RMSE一度低得让我开心,结果换到新数据上预测一塌糊涂。因为验证集的信息已经参与了训练前的缩放,模型相当于提前接触了验证集的分布,这种乐观误差非常误导。
2.3 怎么把一行数据切成一堆样本
具体用代码实现的时候,我习惯先把每天的24小时序列拆开,然后把18个指标各自抽出来,按时间顺序排列成18行、24列的矩阵,再针对每天的矩阵做滑动窗口切片。窗口长度设为9,每切一次得到18×9的二维数据,展平成一个162维的向量,对应的标签是第10小时的PM2.5浓度值。
这里有一个容易搞错的地方:如果是用整个数据集的所有小时连续滑窗,那么在“某天的最后一小时”和“下一天的第一小时”之间,可能会跨天预测。如果这两组数据在物理上确实是连续的,跨天预测还可以接受;但如果跨天有观测中断,那就不能硬跨。我当时的做法是:在每天内部滑窗,不跨天,这样样本之间更干净,虽然样本数量会少一些,但不会引入诡异的数据断档。
3. 线性回归模型实现:从闭式解到手写梯度下降
3.1 线性回归的数学形式与偏置项
线性回归说白了就是学一个权重向量w和一个偏置b,让y等于x和w的内积再加b。为了统一写法,习惯把b也塞进权重里:在特征向量x后面补一个值为1的维度,权重向量也多加一个分量,这样预测值就简化为x和w的向量内积。补上这个维度之后,所有推导和代码都可以用矩阵运算一把梭。
损失函数用均方误差MSE。假设有N个样本,预测值和真实值的差的平方,再取平均。之所以用均方误差而不是绝对值误差,是因为平方函数处处可导,而且梯度的大小和误差成正比,误差越大更新越快。在数学上解起来也方便。对w求梯度时,矩阵形式非常简洁:梯度等于X的转置乘以(预测值减真实值),再除以样本数N。
3.2 解析解:数据量小的时候完全可以秒出结果
线性回归有一个闭式解,也就是正规方程:w等于(X转置乘X)的逆矩阵,再乘X转置乘y。这个解一步到位,不需要迭代。在特征维度162、样本量几千到一万这个量级下,求一个162乘162矩阵的逆毫无压力,运算时间可以忽略不计。
我建议你先用正规方程跑一遍,拿到一个参考RMSE。这个结果基本就是线性模型在当前数据上的“天花板表现”,后续手写梯度下降,要做的就是想办法逼近这个数值。这样你就能知道自己写的梯度下降有没有bug:如果梯度下降收敛后的RMSE明显高于正规方程,说明学习率没调好或者特征没标准化;如果两者差不多,那说明实现基本正确。这个对照方法非常实用,我用它排查过很多次训练不收敛的问题。
3.3 梯度下降:批量、随机、小批量的取舍
作业的核心要求经常是手写梯度下降,不是让你调包。所以你得真的理解每一步在算什么。梯度下降的每一轮迭代都分成三步:用当前权重计算所有样本的预测值,计算梯度,然后沿着负梯度方向更新权重。学习率learning rate控制每一步走多大。
批量梯度下降(BGD)每一轮用所有样本算一次梯度,凸问题上非常稳定,但数据量大时每轮太慢;随机梯度下降(SGD)每轮只用一个样本算梯度,更新飞快,但loss曲线会一直抖动;小批量梯度下降(Mini-batch GD)折中,每次拿一小批样本(比如32或64个)算平均梯度再更新,是实际中的主流选择。这个作业的数据量不算大,用小批量训练时间也才几秒钟,所以我最后用的是小批量,batch size设64。
手写实现时有一个最容易被忽略的细节:更新公式里,权重的更新量是负的学习率乘以梯度。如果梯度算错方向,或者忘记取平均,loss会直接飞出天际。我当时在计算梯度时除以了样本数N,然后在更新时又除以了batch size,等于梯度被缩小了两次,结果收敛慢得离谱。排查了很久才发现是这种重复归一化的问题。
3.4 正则化:为什么加一点点L2能更稳
线性回归做多了你会发现一个问题:如果两个特征高度相关,比如PM10和PM2.5本身有一定相关性,权重就会变得不稳定。模型可能把其中一个特征的系数学得特别大,另一个特别小,两者相互抵消。加L2正则化(也叫岭回归)就是给损失函数加一项,惩罚权重的平方和,让权重整体不会太大。
正则化系数lambda的意义在于平衡“拟合训练数据”和“限制权重大小”。lambda太小,正则化约等于没有;lambda太大,模型会过于简单,欠拟合。我在这个作业里试过,lambda取0.01左右比较合适,验证集RMSE会略微下降1左右,权重也显得更“正常”。要注意的是,偏置项b不应该被正则化,因为偏置只是平移预测值,不参与特征之间的权衡,惩罚它没有必要。
3.5 一个能跑通的numpy实现示例
下面这个核心代码片段是我当年的做法,用numpy手写了训练过程,没有调用现成的模型库。你可以在Jupyter里直接跑,逻辑每一步都对应前面的推导。
import pandas as pd import numpy as np # 假设 train_x: (样本数, 特征维度) 已构造好 # 假设 train_y: (样本数, 1) 已准备好 # 假设 val_x, val_y 同理 def zscore(x): mean = x.mean(axis=0) std = x.std(axis=0) return (x - mean) / (std + 1e-9), mean, std # 标准化 train_x_scaled, mean, std = zscore(train_x) val_x_scaled = (val_x - mean) / (std + 1e-9) # 加偏置维度 train_x_b = np.concatenate([train_x_scaled, np.ones((train_x_scaled.shape[0], 1))], axis=1) val_x_b = np.concatenate([val_x_scaled, np.ones((val_x_scaled.shape[0], 1))], axis=1) w = np.zeros((train_x_b.shape[1], 1)) lr = 0.2 lmbda = 0.01 num_epochs = 500 batch_size = 64 def mse(y_true, y_pred): return np.mean((y_true - y_pred) ** 2) for epoch in range(num_epochs): # 每轮epoch内打乱数据(注意:这是训练阶段的样本顺序打乱) indices = np.arange(train_x_b.shape[0]) np.random.shuffle(indices) train_x_b = train_x_b[indices] train_y = train_y[indices] for start in range(0, len(train_x_b), batch_size): end = min(start + batch_size, len(train_x_b)) x_batch = train_x_b[start:end] y_batch = train_y[start:end] pred = x_batch @ w grad = x_batch.T @ (pred - y_batch) / len(x_batch) # 对权重施加L2正则化梯度,偏置维度不惩罚 grad_reg = grad.copy() grad_reg[:-1, :] += (lmbda / len(x_batch)) * w[:-1, :] w -= lr * grad_reg val_pred = val_x_b @ w val_rmse = np.sqrt(mse(val_y, val_pred)) if epoch % 50 == 0: train_pred = train_x_b @ w train_rmse = np.sqrt(mse(train_y, train_pred)) print(f"epoch {epoch}, train rmse {train_rmse:.3f}, val rmse {val_rmse:.3f}")这段代码的梯度写法用的是“先算预测,再算误差,再乘特征矩阵转置”,和你从数学公式里推出来的一模一样。注意正则化梯度那几行:我额外给权重(除偏置外)加了一项lambda除以N再乘以w,这样才算完整。
4. 实操训练与评估:loss曲线怎么读,模型怎么调
4.1 训练过程中的第一次认知刷新
我第一次训练的时候,初始loss高到吓人,train RMSE一开始是几百,每过几十个epoch才缓慢下降。后来意识到这是因为没做标准化,而且初始权重全为0。在线性回归里,全0初始化是合法的,不等于神经网络的对称问题,loss也一定能下降,但收敛会比较慢。做了标准化之后,第一轮epoch的RMSE就掉到了100以内,视觉上完全不一样。
我建议你每次训练跑完,都把train和validation的RMSE打出来,而不是只记录最终数值。看曲线的下降形态比看最后的单个数字更能发现问题。如果loss下降缓慢得像蜗牛爬,大概率是学习率太小,或者特征还没标准化;如果loss一开始骤降、然后震荡、再然后直接变NaN,通常就是学习率太大爆炸了。
4.2 如何用验证集调超参,而不是拍脑袋
学习率是我在这个作业里花时间最多的地方。我列了个小表,把常用学习率都试了一遍:
| 学习率 | 现象 | 结论 |
|---|---|---|
| 1.0 | 梯度爆炸,loss直接变NaN | 过大 |
| 0.5 | 前几十轮剧烈震荡,后期勉强收敛 | 偏大 |
| 0.2 | 收敛平稳,500轮内足够 | 推荐 |
| 0.01 | 太慢,500轮还没到最优 | 偏小 |
在特征标准化之后,学习率取0.1到0.3之间通常都是合理区间,因为特征的尺度已经统一到0附近,梯度不会因为某个特征单位太大而剧烈变化。如果你用了别的特征工程,数值尺度不同,学习率需要重新调。这个道理放到其他模型里也成立:调超参之前,先确认数据的数值尺度是否真的准备好了。
正则化系数lambda也值得做一次小扫描。我试过0.001、0.01、0.1、1.0,最后0.01的效果最好,RMSE略微下降;1.0的时候模型被压得太狠,train和val的RMSE都变差。这种“过强正则化导致欠拟合”的情况,新手很容易忽略,总觉得正则化是多多益善。
4.3 预测结果怎么分析才不算白做
训练结束后不要只盯着RMSE看,我习惯把真实值和预测值画在一张散点图上,横轴是真实值,纵轴是预测值。如果模型表现理想,点应该密集分布在对角线附近。这个作业里最常见的画面是:中低浓度时点比较贴近对角线,但高浓度时点明显往下掉。原因很朴素:线性模型天然倾向于把极端值拉回均值,重污染时段更容易低估。这不一定是数学模型写错了,而是线性回归的表达能力就到这个程度。
我还看过残差图:横轴是预测值,纵轴是真实值减预测值。如果残差均匀分布在0上下,说明模型没有系统性偏差;如果出现明显的喇叭形,比如预测值越大残差越大,说明模型在极端区域性能不稳。残差分析的价值在于,它能告诉你是该换特征、加特征,还是该换模型。比如我发现高浓度时段残差特别大,后来试着加入了“过去9小时PM2.5的均值”和“过去9小时PM2.5的最大值”这种统计特征,验证集RMSE又降了一点。
4.4 作业里最终跑出的一个参考结果
我用正规方程和梯度下降分别跑了一遍,正规方程的验证集RMSE在15到16之间,梯度下降在调节好学习率之后最终也能收敛到几乎相同的值。这个数值只能作为参考,因为数据划分方式、特征构造细节都会让结果浮动几个点。但重要的是,当你在同一个数据切分方案下看到数字接近这个量级,至少说明整个流程的实现没有问题。
如果发现自己的RMSE比参考值高出很多,不要先怀疑模型,优先检查数据预处理。我见过最多的问题其实是:天数切分错了、滑窗时把预测目标的位置取错(取成了当前时间点而不是未来一小时)、标准化时泄露了验证集信息。这些问题全都会让最终指标变得诡异,但它们和模型实现一点关系都没有。
5. 常见问题与排查:这些坑我估计你也跑一遍
5.1 训练中途loss变成NaN,怎么办
梯度爆炸是NaN最常见的原因。特征里如果有极大数值,或者学习率设得太大,梯度更新会一步跨到很远的错误区域,再下一步就溢出成NaN。排查路径按顺序来:先关掉正则化,看是不是正则项写错;然后把学习率除以10,看是否恢复正常;最后检查所有特征是否有无穷大值,尤其是处理NR和缺失值不彻底时,数据里可能残留非数值对象。我见过有人把NR用pandas的fillna方法替换,但因为那一列原本是object类型,fillna之后仍然有字符串,运算时直接炸。
5.2 训练和验证的RMSE差异巨大
如果训练集RMSE很低,验证集RMSE却高得离谱,先怀疑过拟合或数据泄露,再怀疑切分方式。对于PM2.5时序数据,如果随机切分,验证集里会混入与训练集时间重叠的样本,模型等于见识过验证集的“邻居”,RMSE虚低。反过来,如果按时间切分后验证误差很高,也可能是因为模型没有捕捉到跨季节的浓度变化,比如训练集集中在秋冬,验证集伸到夏季,特征又没包含时间信息,模型自然会失准。这个和模型代码无关,是数据分布漂移的问题。
5.3 梯度计算对了,但收敛特别慢
收敛慢最典型的原因是特征没标准化,其次是小批量梯度下降时batch size和epoch数量不匹配。如果你觉得收敛太慢,先确认train loss的下降率:如果每50个epoch才下降一小点,不是bug,是学习率太低。我记得有人会把学习率设成0.001,在500轮内几乎看不出变化,这不是线性回归失效了,是步子迈太小。另一个细节:如果你的实现里没有把特征矩阵转置对,比如用了x_batch.T @ pred,但pred维度写错了,梯度会变成很大的矩阵或广播出错,训练也会以诡异的方式变慢。
5.4 数据集切分时的一个隐藏考点
我后来回看这个作业,发现它其实有意识地考察你对时序数据的理解。如果你看到作业说“给你前多少天的数据,请预测后一天”,那就不能用随机切分。正确做法是把最后若干天剔出来作为验证集,更严格的做法是:在构造滑窗样本时,确保训练集和验证集的窗口完全没有重叠。我之前有段时间图省事,先构造好所有样本再统一切分,结果某些样本的窗口尾部搭到了验证集样本的窗口头部,等于验证集的信息被训练集偷偷看到了一点点。虽然影响不大,但这种不严谨会在真实项目里放大成很大的问题。
6. 从作业到真实预测:几个没必要再踩的弯
做这个作业的过程里,我后来回头看,发现有三件事最能体现人和人之间的差距:第一是能不能把数据处理流程抽象成函数,第二是能不能把训练集、验证集、评估过程完全隔离,第三是能不能理解“模型当前分数好”和“模型将来能预测好”是两回事。如果你只是照着教程跑通了代码,但让我现在改一个特征或换一种模型就懵,那说明你还没真正掌握这个作业的营养。
我自己的经验是,把作业做完之后,最好多跑几组对照实验。比如把窗口长度从9改成6或者12,看RMSE怎么变;把正则化系数调大调小,看权重变化;把天气相关特征去掉,看模型损失多少。这些对照实验做的次数越多,你就越清楚每个旋钮到底在控制什么。哪怕你以后去做树模型、深度学习,这种“拆解变量影响”的思维也是通用的。
最后再分享一个小技巧:如果你有时间,别急着直接套工具包,先手写一遍线性回归的正规方程和梯度下降。手写的过程会逼你把矩阵的维度、导数的来源、正则项该加在哪一步全部抠清楚。我已经见过太多人调包跑出个loss数字,但问一句“这个loss是怎么算出来的”就答不上来。这个作业的价值本来就不在结果分数,而在于你有没有真的搞懂自己敲下的每一行代码。能把这件事做扎实,后面的课程作业你会轻松很多。