☰
手写线性回归预测PM2.5:从原理到代码的完整复盘
2026/10/17 12:53:50 网站建设 项目流程

这个题目我盯了好一会儿,原因很简单:这是我当年认认真真从零手写、一行库都没调过的机器学习作业。很多人觉得课程作业嘛,调个包跑通就行,但那次我硬是拿numpy把线性回归、梯度下降、特征工程全部手撸了一遍。结果就是,后面再遇到各种看似高深的模型,我一点都不慌,因为最底层的计算逻辑已经被我亲手拆开揉碎过一遍了。

这篇就完整复盘一下这个“预测PM2.5”的作业:题怎么拆、数据怎么处理、模型怎么选、代码怎么写、坑怎么踩,最后再把一些可以迁移到其他项目的经验一并交代清楚。无论你是刚开始学机器学习的学生,还是想补基础底子的转行者,这篇应该都能帮你省下不少摸索的时间。

1. 题目拆解与方案选型:为什么是线性回归,为什么非要手写

1.1 题目的真实意图:练的不是预测,是原理

先说作业本身。数据集是某高校机器学习课程提供的,内容是某地区一整年的气象观测数据,每行记录了一个观测站某个小时的18项指标,包括PM2.5浓度、PM10浓度、NO2、CO、O3、风速、雨量、日照等。任务是给定前9个小时的观测数据,预测第10个小时的PM2.5值。看起来是一个时间序列预测问题,但它其实是课程用来练线性回归的。

为什么选线性回归?因为它是最基础、最直观、也最能暴露你“到底懂不懂机器学习”的模型。逻辑回归适合分类,树模型适合非线性,但线性回归把“特征加权求和加偏置,再最小化误差”这个过程展现得干干净净。梯度下降、损失函数、过拟合、正则化这些核心概念,都能在线性回归上找到最直观的落脚点。

我当时还纠结过要不要直接上LSTM或者XGBoost,后来想想不对,作业要的是让你理解模型参数是怎么被训练出来的,不是让你炫技。用10行代码调一个xgboost跑出好看的分数,对你理解机器学习帮助为零。手写线性回归虽然笨,但它逼着你去面对每个细节:梯度怎么算、学习率该设多少、特征要不要归一化、数据怎么切才不会偷看未来信息。

1.2 手写代码和调库的本质差别

很多同学觉得作业用sklearn的LinearRegression跑一下,再report一个RMSE就完事了。不是说不行,而是这样你永远只能在“调包侠”的水平线上打转。手写代码强迫你回答几个关键问题:

  • 梯度下降的更新公式为什么是w -= learning_rate * gradient,而不是w += learning_rate * gradient?
  • 损失函数对w求导时,那个转置到底怎么来的?为什么要转置?
  • 数据归一化后,预测结果怎么还原成原始量纲?
  • 正则化项加在损失函数里,梯度的表达式会发生什么变化?

这些问题你在sklearn里永远碰不到,因为fit方法封装得干干净净。但真实工作中,你迟早会遇到“现成库跑不了”的场景,那时候你能依赖的只有原理本身。我自己的体会是,手写一遍等于把“黑盒模型”变成“灰盒模型”,后面学神经网络、学深层模型,脑子里始终有那张矩阵乘法的图。

1.3 方案选型的思考链路

我最终确定的方案是:纯numpy手写线性回归,用批量梯度下降(Batch Gradient Descent)训练,搭配L2正则化防止过拟合,特征用StandardScaler做标准化,评价指标用RMSE和MAE。选这个组合的理由相当朴素:

  • 数据量不大(不到4000条样本,18个特征),批量梯度下降完全跑得动,没必要上SGD或Mini-batch增加复杂度。
  • 加上L2正则化是因为特征经过多项式扩展后维度会膨胀,不加正则很容易在训练集上自嗨。
  • 不用正规方程(Normal Equation),因为梯度下降才是课程的核心考点,而且正规方程在特征维度高的时候计算逆矩阵代价很大,不利于后续扩展。

这个选择在当时看起来偏“笨”,但后来我意识到,批量梯度下降其实帮你自动完成了对学习率的敏感性测试,这对理解优化算法非常有帮助。

2. 数据预处理与特征工程:把原始表格变成能喂给模型的模样

2.1 原始数据的真实长相

坦白讲,第一次拿到数据的时候我是有点懵的。CSV文件里根本不是标准的一行一个样本,而是按“日期+小时”的行组织方式。每一天有24行,每行有18个特征列,然后后面还有第19列是当小时的PM2.5测量值。这意味着:

  • 如果整表直接喂给线性回归,模型会把“同一日期的第14行”和“第15行”当成完全独立的样本,丢失了时间顺序信息。
  • 要预测第10小时的PM2.5,需要把前9个小时的数据拼接成一个长的特征向量。也就是说,样本的构造方式要从“逐行预测”改成“按滑动窗口拼接”。

我当时设计了一个窗口函数,简单说就是把连续9个小时的18维特征拼接成162维向量,然后对应第10个小时的PM2.5作为标签。这样构造出来的数据集,每一行才是真正意义上的独立样本。这个思路和现在所有时间序列预测模型的数据预处理方向一致,只是当时做起来很原始。

2.2 缺失值处理的三个备选方案

数据里有不少RAINFALL字段是空值(NR),表示没有降雨。直接填0其实问题不大,因为雨量是连续值,没下雨用0代替是合理的。但更规范的方案是看这个特征与目标值的相关性,如果当前列缺失占比过高,干脆丢弃这一列,否则就用该列均值填充。

我的做法是:

  • 对数值型特征列,统计缺失比例,缺失超过50%的直接删掉。
  • 缺失比例较低的列,用该列均值填充。
  • RAINFALL字段统一填0,因为“NR”在这里语义明确就是无雨。

这里有个小坑容易被忽略:填充均值时必须使用训练集的均值,不能把测试集也统计进去。数据和标签分开前,你就把整个数据集的均值算好,塞到测试集里,那就是标签泄露,会让分数虚高,但实际部署时完全失效。我自己第一次就没注意,后来验证集分数和公有分数差距很大,排查半天才发现是这个问题。

2.3 标准化:为什么线性回归一定要做这一步

线性回归的特征量纲差异极大。PM2.5浓度可能只有几十,而CO浓度可能只有0.5左右,但风速可能是几十,气压可能上千。如果不做标准化,梯度下降会走得非常痛苦:某些维度的梯度大得离谱,某些维度梯度小得可怜,整个训练过程像瘸腿走路,不仅收敛慢,还容易震荡不收敛。

标准化公式很简单:

x_scaled = (x - mean) / std

用numpy实现也就是两行代码的事。但要注意三点:

  • 要保存训练集的mean和std,测试集和真正预测时都用同一组参数来变换,不能各自算各自的。
  • 预测出标准化后的PM2.5值后,要反过来用训练集标签的mean和std还原成真实数值,这样才能算RMSE。
  • 标准化不仅对输入特征有必要,对标签做标准化也常常能提升数值稳定性。我当时把标签也标准化了,训练时损失函数的初始值会变得很小,收敛速度肉眼可见地加快。

2.4 特征扩展:为什么把9小时拼一起还不够

只把前9小时的18维特征拼成162维,线性回归能捕捉的信息其实有限。PM2.5的浓度变化受风力、湿度、前一天浓度共同影响,这些关系未必是线性的。所以我又做了两件事:

  • 把第9小时的PM2.5单独拿出来,乘以一个较大的权重,因为显然最近一小时的浓度对下一小时有极强的影响。
  • 对部分特征(尤其是PM2.5、PM10、CO)计算二阶交叉特征,例如PM2.5和风速的乘积。风速大时污染物扩散快,这种交互效应用乘积特征才能表达出来。

特征扩展后维度从162变成了接近200,配合L2正则化完全能hold住。这里我的体会是:特征工程不是越复杂越好,但要基于对业务的理解做合理扩展。你可以不懂大气科学,但你至少要知道“风速越大污染物浓度越低”这种常识,它就是一组有意义的交叉项。

3. 核心算法原理:手写梯度下降时脑子里的那张图

3.1 线性回归的目标函数和损失函数

线性回归的核心假设是:目标值y是特征向量x的线性组合加上一个偏置b,也就是:

y_pred = w^T x + b

为了表达方便,通常把b并进w里,在x前面加一列全1,这样可以直接写成矩阵相乘。

损失函数用均方误差MSE,公式是:

L = (1/n) * sum((y_pred - y)^2) + lambda * sum(w^2)

后面那项是L2正则化项。为什么加它?因为在样本量相对特征维度不够大的时候,模型很容易记住训练集的噪声,导致在验证集上表现糟糕。加了正则项之后,模型被强迫往“权重尽量小”的方向走,相当于给模型套了缰绳。

3.2 梯度推导:那个转置到底哪来的

手写代码真正考验人的就是梯度计算。对损失函数求w的偏导:

dL/dw = (2/n) * X^T (y_pred - y) + 2 * lambda * w

这里的关键是理解X^T为什么要乘残差向量。可以这样想:X的形状是(n, d),残差是(n, 1),X^T就是(d, n),矩阵乘完之后得到(d, 1),正好和w的形状对得上。如果不小心写成X乘以残差,得到的是(n, 1),维度完全错乱,程序直接报错。

我当时是硬推导了两遍才想明白这个转置。实际写代码时更安全的做法是先算出预测值、残差,然后再算梯度,而不是直接在纸上推完就往代码里抄。推荐新手先在代码里print出梯度矩阵的shape,确认是(d, 1)再做减法。

3.3 批量梯度下降的完整更新过程

训练过程写成伪代码就是:

初始化 w 为零向量 repeat: y_pred = X @ w error = y_pred - y gradient = (X.T @ error) / n + lambda * w w = w - learning_rate * gradient until 损失变化小于阈值 或 达到最大迭代次数

这里有一个细节:梯度里正则化项通常不对偏置b做惩罚,所以如果w和b是分开存的话,要记得给b的梯度去掉正则项。我为了方便直接把b塞进w的第一列,这样所有维度一起更新,虽然严格来说不够严谨,但实际影响非常小,尤其是在样本量不小的情况下。

学习率的选择可以先用一组小实验来试:分别用0.1、0.01、0.001跑100轮,看损失曲线是震荡、平稳下降还是几乎不动。我最后选定的学习率是0.001,迭代2000轮,损失曲线下降得非常平稳,接近收敛时也没有明显震荡。

4. 实操过程与关键代码实现:从零到能提交的完整方案

4.1 数据读入与预处理代码

整个流程的第一部分是读数据、造特征、切训练验证集。我用的就是pandas+numpy的组合,没有调用任何机器学习库。核心代码大致长这样:

import numpy as np import pandas as pd data = pd.read_csv('train.csv', encoding='utf-8') # 原始数据中 RAINFALL 的 NR 需要替换为数值 0 data.replace('NR', 0, inplace=True) data = data.astype(float) # 按日期分组,每天24个小时,18个特征 months = data.groupby(['month', 'day']) def build_sample(group): # group 是某天24行的数据 arr = group.iloc[:, 2:].values # 去掉前两列标识列 window = [] for hour in range(9): window.append(arr[hour]) feature = np.concatenate(window) label = arr[9][-1] # 第10个小时的PM2.5 return feature, label features = [] labels = [] for _, group in months: f, l = build_sample(group) features.append(f) labels.append(l) X = np.array(features) y = np.array(labels)

这段代码有个地方要非常小心:按日期分组时,如果原始数据里某个日期缺了若干个钟头,拼接出来的窗口就会错位。在我自己处理的过程中,数据整体比较规整,没有出现大问题,但严谨起见可以在build_sample里面加一个长度校验,如果当天不足24行就直接跳过或者报错。

最后再把X,y划分成训练集和验证集,比例大概是8:2。切分要打乱顺序吗?这里要特别注意。因为样本是按日期顺序构造的,直接按顺序切会让训练集全是前半年的数据,验证集全是后半年的,导致季节分布不均衡。我当时的做法是先随机打乱所有样本的索引,再切分。这是完全合理的,因为我们的目标是预测“任意时刻的下一小时”,不是预测“未来一年”,所以样本独立性假设成立。

4.2 标准化与特征扩展的落地实现

特征扩展的逻辑放在标准化之后做还是之前做?我建议先标准化,再做交叉特征。原因是如果不先标准化,降水量几千的数值和PM2.5几十的数值乘起来会得到一个天文数字,严重拖慢梯度下降。标准化之后再相乘,数值范围就合理多了。

mean = X_train.mean(axis=0) std = X_train.std(axis=0) std[std == 0] = 1 # 防止某列方差为0导致除零 X_train_scaled = (X_train - mean) / std X_val_scaled = (X_val - mean) / std # 添加交互特征 X_train_inter = np.hstack([ X_train_scaled, X_train_scaled[:, [8]] * X_train_scaled[:, [6]], # PM2.5与风速 ]) X_val_inter = np.hstack([ X_val_scaled, X_val_scaled[:, [8]] * X_val_scaled[:, [6]], ])

这里的索引是我临时编的,实际要看你数据里第几列是PM2.5、第几列是风速,别直接照抄。做特征扩展前,建议把列名打印出来一一核对,避免乘了一个莫名其妙的特征组合。交叉特征不需要做太多,选两三个业务上说得通的就行。加多了不仅训练变慢,还容易引入噪声。

4.3 手写线性回归模型主体

这是整个作业的核心,也是我最有感情的一段代码。所有逻辑都在一个类里,清晰又方便测试:

class LinearRegressionManual: def __init__(self, lr=0.001, n_iters=2000, reg_lambda=0.1): self.lr = lr self.n_iters = n_iters self.reg_lambda = reg_lambda self.w = None def fit(self, X, y): n_samples, n_features = X.shape self.w = np.zeros(n_features) X_b = np.hstack([np.ones((n_samples, 1)), X]) # 增加一列1 self.w = np.zeros(n_features + 1) for i in range(self.n_iters): y_pred = X_b @ self.w error = y_pred - y gradient = (X_b.T @ error) / n_samples # 不对偏置项加正则化 gradient[1:] += self.reg_lambda * self.w[1:] self.w -= self.lr * gradient if i % 200 == 0: loss = np.mean(error ** 2) + self.reg_lambda * np.sum(self.w[1:] ** 2) print(f'iter {i}, loss {loss:.4f}') def predict(self, X): X_b = np.hstack([np.ones((X.shape[0], 1)), X]) return X_b @ self.w

这段代码你拿去改改学习率和迭代次数就能用。有几点心得:

  • 损失打印不需要每轮都print,否则控制台刷屏不说,还拖慢训练速度。每200轮打印一次刚好。
  • 梯度更新的时候正则项只加在gradient[1:]上,这和前面说的偏置不惩罚一致。虽然影响很小,但这是思路严谨性的体现。
  • 如果loss出现NaN,优先检查特征里是否有无穷大值,或者学习率过大导致数值发散。

4.4 验证与评价指标的实现

训练完之后,要分别计算训练集和验证集的RMSE和MAE。这俩指标的意义不同:RMSE对大误差非常敏感,一个极端预测能拖垮整体分数;MAE更鲁棒,反映平均偏差水平。我建议同时看两个,如果RMSE明显大于MAE,说明模型对异常值敏感,可以考虑对标签做截断或者换更稳健的损失函数。

y_pred_train = model.predict(X_train_inter) y_pred_val = model.predict(X_val_inter) # 还原标签标准化 y_pred_train_real = y_pred_train * y_std + y_mean y_pred_val_real = y_pred_val * y_std + y_mean rmse_train = np.sqrt(np.mean((y_pred_train_real - y_train_real) ** 2)) rmse_val = np.sqrt(np.mean((y_pred_val_real - y_val_real) ** 2)) print(f'Train RMSE: {rmse_train:.3f}') print(f'Val RMSE: {rmse_val:.3f}')

如果训练集RMSE很低但验证集RMSE高,就是过拟合了。我当时训练集RMSE大概在12左右,验证集在16左右,差距不算大,说明特征扩展配合正则化把握得还可以。如果差距超过30%,就该减少特征维度或增大正则化系数。

5. 常见问题与排查技巧实录:那些年踩过的坑

5.1 损失函数爆炸,直接变NaN,怎么办

这个问题几乎每个手写梯度下降的人都遇到过。表现是训练到第几十轮时loss突然变成nan,然后永远回不来。原因通常是两种:学习率太大导致更新步长越过最优解,或者数据里有异常值导致梯度计算数值溢出。

我当时排查的顺序是这样一个问题一个问题的验证:

  • 先打印前10轮的loss,看看是初始就nan,还是训练到后面才nan。如果是初始就nan,检查数据预处理(是否有inf或空值没替换干净)。
  • 如果是后面才nan,把学习率除以10重跑一遍,如果不再nan,说明学习率过高。
  • 再把损失函数里的正则项系数从0.1调成0.01,排除正则项梯度过大的可能。

实际结果我那次是学习率设了0.01,对归一化后的数据来说偏高。一路降到0.001才稳定。经验是:标准化的数据,学习率从0.001起步,观察曲线后再考虑增大。不要一上来就设0.1,除非你想看烟花。

5.2 验证集分数虚高但提交分数崩塌:数据泄露

这个问题比NaN更阴险,因为它表面上损失曲线完美,结果也漂亮,但一上真正的测试集就露馅。我当时犯的错误就是在构造样本时,把第10个小时的数据混进了前9小时的特征里。

怎么发生的?我在分组处理时,为了图方便,把当天的18列全部塞进了特征矩阵,然后标签用的也是同一行的PM2.5值。这样模型看到的不只是前9小时,而是第10小时的PM2.5直接作为特征通向输出。训练时RMSE能低到逼近0,但测试集上直接原形毕露。

排查方法其实很简单:你把训练好的模型权重打出来,如果发现某个特征的权重异常大(几百上千),说明该特征和标签几乎共线,八成是数据泄露了。

5.3 训练慢得像蜗牛,是代码问题还是数据问题

还有一次我发现训练速度极慢,每轮都要跑很久。后来发现是我的梯度计算用了循环遍历每一条样本,而不是矩阵运算。numpy的矩阵乘法底层是经过优化的C代码,手写for循环跑几千条样本,每轮都要花掉几百毫秒。改成X_b.T @ error之后,训练直接从几分钟降到了十几秒。

经验教训是:能用矩阵运算就绝不用循环。这也是手写代码过程中最大的性能提升点。你要是把循环版交上去,助教大概率会说“功能正确但效率堪忧”。

5.4 常见问题速查表

现象可能原因排查/解决方法
loss初始就为NaN数据有空值/inf未处理检查预处理,替换空值和无穷值
loss训练中爆炸学习率过大调小学习率,或对数据标准化
训练慢用了for循环而非矩阵运算检查梯度计算,改为矩阵乘法
训练集完美但验证集差特征泄露或严重过拟合检查特征是否混入标签,增加正则化
验证集还行但提交崩数据切分没打乱,类别不均衡打乱数据再切分,确认同分布
权重数值异常大特征未标准化或数据泄露标准化特征,检查特征与标签关系
预测结果全一样模型退化或偏置太大检查特征方差,确认梯度是否更新

6. 从作业到实战:这几次手写到底给我留下了什么

6.1 直接能迁移到工程里的几个习惯

手写完成这个作业后,我最大的收获不是“我会调线性回归了”,而是建立了几个条件反射式的习惯:

第一个习惯是拿到数据先看形状和缺失值,不急着建模。任何一个工业级项目,数据的脏乱程度都远超课程作业。先打印info、describe,确认列数、行数、缺失值占比,再动手预处理,能省掉后面90%的排查时间。

第二个习惯是每次训练都要划分留出验证集,并且从头到尾只用训练集的统计信息来预处理数据。无论是均值、标准差还是缺失值填充,测试集和验证集都要复用训练集的那组参数。这一步做对了,你的模型泛化能力才谈得上可信。

第三个习惯是监控训练指标的曲线,而不是只看最终分数。损失函数是否平滑下降、是否震荡、是否突然跳变,这些曲线里写满了训练过程的健康状况。很多时间序列预测项目里,只盯着一个最终RMSE很容易被数字欺骗,曲线才是诊断工具。

6.2 如果重新做一次,我会改进哪里

要是让我现在重新做这个作业,我会在特征和模型两个方向上作点升级:

特征方面加上滞后N小时的PM2.5均值、滚动平均、小时周期编码,比如把小时数转成sin和cos分量来捕捉昼夜节律。这些对空气质量预测确实有真实增益,而且实现起来并不复杂。模型方面可以试试加一层特征交叉后的岭回归,或者用交叉验证来调正则化系数,而不是靠手试。

另外一个值得尝试的方向是把9小时窗口改成可变长度窗口,对比不同窗口长度下模型的表现。窗口太短捕捉不到趋势,太长则引入冗余信息。选择一个在验证集上表现最好的窗口长度,本身就是一种特征选择。

6.3 给后来者的几句实在话

如果你也要做类似的课程作业,我不建议你直接复制粘贴任何人的代码,包括我这篇里的示例。你可以参考思路、看懂结构,但一定要亲手敲一遍。因为手写代码的过程中,你最缺的不是“会调用某个函数”,而是“知道它在干什么”。同样一段归一化代码,自己敲一遍才会知道std为0的列会导致除零;同样一段矩阵梯度,自己推导一遍才清楚转置从哪里来。

还有个非常实用的建议:每实现一个功能,就写一小段测试来验证它。比如标准化之后,检查每列均值是否接近0,方差是否接近1;训练完之后,手动计算几条样本的预测值和模型输出对比,确保没算错。这些看似繁琐的检查,能帮你把错误拦截在早期。

最后,别害怕调参。学习率、正则化系数、迭代次数,这些参数没有绝对的标准答案。用手试,用曲线验证,你会慢慢培养出一种直觉:loss下降太快可能是学习率过大,下降太慢要加大学习率,震荡则说明步子迈大了。这种直觉,不是看书看出来的,是亲手调出来的。

回头看,不就是一次普普通通的课程作业吗?但它确确实实帮我打下了机器学习的基础。希望这篇复盘,也能帮你在同样的路上走得顺一点。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询