简介:基于线性回归的细颗粒物(PM2.5)浓度预测项目,是一份面向计算机专业学生、毕业设计人员和机器学习初学者的课程设计类完整资料,适合用于期末大作业或项目实战练习。该项目以空气质量预测为场景,完整演示了从数据清洗、特征拼接、线性回归建模到预测结果分析的整个流程,能帮助理解线性回归在真实数据上的应用方式。压缩包共19个文件,包括12个CSV格式的数据集(训练数据、测试数据、预测结果等)、3个Python程序文件(核心算法与执行脚本)、1个Markdown格式的说明文档(项目介绍与使用说明)、1张预测效果示意图、1个测试文件以及1个NumPy模型参数文件,整个压缩包大小约2.39MB,结构清晰便于按需学习与复用。该项目经导师指导并获得99分的评审成绩,代码完整可运行,适合作为高分参考模板,灵活迁移到其他回归预测任务中;目前已有154人学习使用,对于正在完成课程设计或准备毕业设计的读者具有实用参考价值。
1. 把 PM2.5 预测做成线性回归大作业,反而是最稳的选择
临近机器学习大作业截止,一种让人头皮发麻的场景是:同组同学用 LSTM 调了一周还没收敛,你手里只有一台笔记本和三天时间。基于线性回归的 PM2.5 预测这类题目,恰恰是机器学习大作业里最稳的出口——数据公开、python 源码逻辑短、每一步都有明确的可视化产物,高分靠的不是模型花哨,而是数据定义干净、评估闭环完整。这篇笔记按"预测目标怎么定、数据处理怎么做、线性回归怎么调、坑在哪"展开,适合想几天内交出一份能答辩项目、并把 R² 稳定做到 0.85 以上的同学。
2. 数据集与问题定义:先搞清楚预测目标和高分作业的评分逻辑
2.1 数据字段与预测目标:PM2.5 预测到底预测的是哪个数
绝大多数大作业用的都是 UCI 的 Beijing PM2.5 数据集,字段固定:year、month、day、hour、pm2.5 浓度、露点 DEWP、温度 TEMP、气压 PRES、风向 cbwd、风速 Iws、积雪 Is、降雨 Ir。也有人用本地环境监测站导出的历史 CSV,字段更乱,但处理思路一样:先把时间整理成连续索引,再决定"用什么预测什么"。
我见过太多翻车的大作业,第一步就栽在目标定义上。你要预测的是下一小时浓度,还是未来 24 小时的平均浓度?线性回归在长步长上误差累积非常快,把"多步预测"硬塞给普通最小二乘,结果往往不如"取过去 24 小时均值当作预测值"这种朴素基线。所以常见做法是只做单步预测:用 t 时刻及之前的信息预测 t+1 时刻的 pm2.5,文档方法部分写一句"预测步长为 1 小时",评分人一眼就懂。如果题目明确要求 24 小时预测,那关键点是直接用 t 时刻及之前的数据拟合 t+24 的浓度,中间第 t+1 到 t+23 小时的数据只能用于生成历史滞后特征,绝不能混进训练标签。
这里有个容易被答辩追问的细节:预测目标的列名和生成方式必须留在源码里可见。我一般会写 df["pm2.5_next"] = df["pm2.5"].shift(-1),放在数据加载函数的最前面,并在文档里画一条时间轴说明特征的观测截止时间。这样即便预测结果不理想,评分人也知道你的问题定义是清楚的。
2.2 滑动窗口构造样本:把时间序列切成监督学习的形状
线性回归是监督学习模型,输入是 X 矩阵、输出是 y 向量,每一行是一条独立样本。PM2.5 原始数据是时间序列,本质上只有一列带时间戳的浓度,想让模型看到"历史上发生了什么",就必须自己动手把历史观测和未来目标拼到同一行。这一步在机器学习里的专业叫法是滑动窗口构造,在大作业里是最容易被省略、却又直接影响预测质量的一步。
下面这段是构造样本的核心代码,我建议单独放一个函数,方便在文档里对着截图讲:
import pandas as pd import numpy as np def build_samples(df, target_shift=-1, lag_hours=(1, 3, 6, 12)): # target_shift=-1 表示预测下一小时;若做提前 24 小时预测,改成 -24 df = df.sort_values("time").reset_index(drop=True) df["y"] = df["pm2.5"].shift(target_shift) for h in lag_hours: df[f"pm2.5_lag_{h}"] = df["pm2.5"].shift(h) return df.dropna().reset_index(drop=True)逻辑说明:sort_values("time") 是前提,如果原始文件日期没有严格排好,shift 出来的滞后值就是错位的;很多爬来的 CSV 会按站点分组,先按时间排序再做窗口是必须的。shift(target_shift) 把目标值上移,shift(h) 把历史值下拉对齐,dropna 删除头部没有完整历史窗口的样本。
参数说明:lag_hours 决定模型能看到多远的过去。小时级数据我一般先取 1、3、6、12 四档:1 小时捕捉短期突变,3 到 6 小时覆盖污染过程演变,12 小时引入昼夜特征。滞后窗口越大,有效样本起始点越靠后,数据量损失也越多,加特征前先算一下手里还剩多少行。顺带一提,pandas 2.x 对字符串列和缺失值的行为更严格,跑前先 df.info() 瞄一眼类型。
2.3 训练集/验证集切分:时间序列乱 shuffle 是第一个翻车点
切分这一节,几乎每个线性回归大作业里都有人在这里摔跤。原因是 sklearn 的 train_test_split 默认 shuffle=True,原始数据是时间序列,一旦随机打乱,2015 年的样本可能进训练集,2010 年的样本进测试集,模型等于提前看到了未来,测试集 R² 飙到 0.99。老师画一张时间轴图,当场拆穿。
split_idx = int(len(df) * 0.8) # 按时间先后切分,不 shuffle X_train, X_test = X.iloc[:split_idx], X.iloc[split_idx:] y_train, y_test = y.iloc[:split_idx], y.iloc[split_idx:] print(f"train period: {df['time'].iloc[0]} ~ {df['time'].iloc[split_idx-1]}") print(f"test period: {df['time'].iloc[split_idx]} ~ {df['time'].iloc[-1]}")逻辑说明:手写切片而不是给 train_test_split 传 shuffle=False,好处是区间边界显式出现在源码里,可读性和答辩安全性都更好。print 出两段时间范围,这份日志截屏放进作业文档,就是最好的"数据划分说明"。
参数说明:8:2 是默认习惯,但我建议先看测试集的月份分布,别让测试集恰好落在污染严重的供暖季。如果训练集和测试集的季节分布严重失衡,模型在测试集上会系统性偏低,这时候不是线性回归不行,而是划分方式有问题。改成按自然年切分(用 2014 全年训练、2015 全年测试),通常比固定 8:2 更抗辩。
除了切分,加载时还有一个脏数据坑:部分版本的 CSV 把缺失值写成 "NA" 字符串,pd.read_csv 不指定 na_values 会把这一列读成 object 类型,后面做回归直接报错。我习惯在加载时统一声明:
df = pd.read_csv("BeijingPM20100101_20151231.csv", na_values=["NA", "--", ""]) df["pm2.5"] = df["pm2.5"].ffill().fillna(df["pm2.5"].mean())这样 pm2.5 列的缺失值在进入建模前就已经统一处理成 float,后面做滑动窗口也不会因为字符串拼接报错。这两行代码的意思后面会细讲,这里只强调一点:数据处理顺序出了问题,线性回归再标准也救不回来。
3. 特征工程与数据清洗:线性回归能不能拿高分,七成在特征
3.1 缺失值与异常值:PM2.5 数据的脏比想象中多
pm2.5 传感器在某些时段会整体掉线,恢复后浓度又从低处回升,这类结构性缺失如果直接 dropna,会一次丢掉连续几十个小时的样本,数据集容量大幅缩水。常见做法是前向填充,因为 PM2.5 是强自相关变量,上一个有效值比全局插值更接近真实浓度;填充完再对整个列做一个均值兜底,防止序列开头连续缺失导致填充链断裂。
import pandas as pd import numpy as np df["pm2.5"] = df["pm2.5"].replace(-999, np.nan) # 传感器哨兵值先还原成 NaN df["pm2.5"] = df["pm2.5"].ffill().fillna(df["pm2.5"].mean()) print(df["pm2.5"].describe())逻辑说明:replace(-999, np.nan) 处理的是某些版本里用 -999 表示仪器异常的情况,不替换的话这个数值会被当成真实浓度参与回归,单条样本就能把 RMSE 拉高几十。ffill 是前向填充,fillna(mean()) 是最后兜底,两行合起来覆盖了"单个空洞"和"整段缺失"两类情况。
参数说明:前向填充不是万能的,如果缺失段超过 6 小时,建议改用线性插值 df["pm2.5"].interpolate(method="linear"),避免把一段 12 小时的缺失全填成同一个值。判断标准很简单:填充后打印 describe,看 min 和 max 是否还在合理区间,比如 pm2.5 浓度出现负数,就说明还有脏数据没清干净。
3.2 相关性筛选与类别变量处理:风向不能直接乘系数
特征不是越多越好,线性回归的一大特点就是特征之间一旦共线性明显,系数估计的方差会变大,单个特征的系数符号都可能反掉。我先用 df.corr()["y"] 把特征和目标的相关性排序,挑出相关性强的天气变量和滞后浓度,弱相关的先不管,防止它们在报告里制造噪音。年份、月份这类列我一般直接剔除,因为 year 和 month 对建模没有增益,还会让标准化后的系数解释变得很别扭。
风向 cbwd 是字符型类别变量,常见的取值有 cv(静风)、NE、NW、SE 几种,不能直接参与乘法运算,必须独热编码。独热编码有一个经典坑叫哑变量陷阱:四类风向用四列二进制编码后,四列之和恒等于 1,与截距项产生完全共线性,sklearn 不报错但系数解释会失真。解决方法是 drop_first,把第一个类别作为参考类,只保留其余三列。
df = pd.get_dummies(df, columns=["cbwd"], drop_first=True) print([c for c in df.columns if c.startswith("cbwd")])逻辑说明:get_dummies 会把 cbwd 展开成 cbwd_NE、cbwd_NW、cbwd_SE、cbwd_cv 等列;drop_first=True 丢弃第一类,剩下三列都是 0/1 独热标志。打印出来的列名列表要写进文档,后面建模时特征列表照着填,少一列模型也能跑,但结果解释就歪了。
参数说明:如果类别变量不是风向而是城市名、站点名,drop_first 同样适用。独热编码的代价是维度膨胀,几十个类别就是几十列,样本量不够时线性回归会过拟合,这时可以考虑改用目标编码,或者直接把这类信息从特征里去掉。
3.3 标准化:为什么线性回归必须做,以及一个防泄漏细节
线性回归本身对量纲不敏感,把温度从摄氏度换成华氏度,系数会按比例缩放,预测结果不变。但大作业里还是建议做标准化,理由有两个:一是如果后面对比 Ridge 或 Lasso,正则化惩罚项对量纲敏感,不做标准化时取值范围大的特征会被惩罚得更重;二是标准化之后,系数大小才能横向比较,作为特征重要性的粗略证据放进报告。
from sklearn.preprocessing import StandardScaler scaler = StandardScaler() X_train_scaled = scaler.fit_transform(X_train) X_test_scaled = scaler.transform(X_test)逻辑说明:这里故意用 fit_transform 和 transform 两种方法,强调"训练集 fit、测试集只 transform"。StandardScaler 计算的是均值和标准差,如果对整个数据集先 fit 再切分,测试集的统计信息会混进训练过程,这属于数据泄漏,是答辩时的高频提问点。
参数说明:StandardScaler 默认 with_mean=True、with_std=True,对应 z-score 标准化。如果特征里有大量稀疏 0/1 列,可以改成 with_mean=False 避免破坏稀疏性;但对 PM2.5 这种稠密表格数据,默认参数足够。想换 MinMaxScaler 也可以,它把数据压缩到 [0,1] 区间,对线性回归预测结果影响不大,但画图时 z-score 的均值 0 更容易讲清楚。
特征工程做完,最好把相关系数矩阵存成一张图放进文档。这一步不增加代码复杂度,但能向评分人证明你不是把全部列一股脑塞给模型的黑匣子选手:筛选前后各跑一次模型,把 R² 的变化写在表里,比空口说"我做了一堆特征"有用得多。
4. 用 Python 实现 PM2.5 预测:一份可以直接交作业的源码流程
4.1 最小可运行版本:从加载到出预测结果
前面做了大量准备工作,到这里把它们串成一个能跑的 python 脚本。我一般把所有步骤收进 main() 函数,数据清洗、特征构造、训练、评估各自独立成函数,这样在命令行跑和 Jupyter 里逐格跑结果一致,也方便换数据集后一键重跑。
import pandas as pd import numpy as np from sklearn.linear_model import LinearRegression from sklearn.preprocessing import StandardScaler from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score FEATURES = ["DEWP", "TEMP", "PRES", "Iws", "Is", "Ir", "pm2.5_lag_1", "pm2.5_lag_3", "pm2.5_lag_6", "pm2.5_lag_12", "hour_sin", "hour_cos", "cbwd_NE", "cbwd_NW", "cbwd_SE"] def load_data(path): df = pd.read_csv(path, na_values=["NA", "--", ""]) df["time"] = pd.to_datetime(df[["year", "month", "day", "hour"]]) df = df.sort_values("time").reset_index(drop=True) df["pm2.5"] = df["pm2.5"].ffill().fillna(df["pm2.5"].mean()) df["y"] = df["pm2.5"].shift(-1) for h in (1, 3, 6, 12): df[f"pm2.5_lag_{h}"] = df["pm2.5"].shift(h) df["hour_sin"] = np.sin(2 * np.pi * df["hour"] / 24) df["hour_cos"] = np.cos(2 * np.pi * df["hour"] / 24) df = pd.get_dummies(df, columns=["cbwd"], drop_first=True) return df.dropna().reset_index(drop=True) def main(): df = load_data("BeijingPM20100101_20151231.csv") X, y = df[FEATURES], df["y"] split_idx = int(len(df) * 0.8) X_train, X_test = X.iloc[:split_idx], X.iloc[split_idx:] y_train, y_test = y.iloc[:split_idx], y.iloc[split_idx:] scaler = StandardScaler().fit(X_train) X_train_s = scaler.transform(X_train) X_test_s = scaler.transform(X_test) lr = LinearRegression() lr.fit(X_train_s, y_train) y_pred = lr.predict(X_test_s) mae = mean_absolute_error(y_test, y_pred) rmse = mean_squared_error(y_test, y_pred, squared=False) r2 = r2_score(y_test, y_pred) print(f"MAE={mae:.2f} RMSE={rmse:.2f} R2={r2:.4f}") if __name__ == "__main__": main()逻辑说明:load_data 里先拼接 time 列并排序,解决原始文件乱序问题;hour_sin 和 hour_cos 属于周期编码,把 hour=12 和 hour=0 的相似性表达出来,避免 23 点和 0 点被当成两个完全不同的数值;整个函数以 dropna 收尾,返回的每一行都有完整的目标和滞后特征,后续建模不会再被 NaN 中断。
参数说明:FEATURES 列表是全局可改的,做消融实验时(比如去掉所有滞后特征再看 R² 掉多少)只需要改这一个列表,不用动模型代码。split_idx 的 0.8 建议改成按时间边界切,比如用 2010 到 2014 年底做训练、2015 全年做测试,答辩时解释起来更有说服力。
4.2 算法选型与参数:线性回归、岭回归和 SGD 怎么选
作业主模型一般用 sklearn 的 LinearRegression,它默认走最小二乘的封闭解,几十个特征的规模完全够用,不需要调学习率。但如果你想在文档里增加对比实验,常见配置是 Ridge 和 SGDRegressor。三者适用场景差别很大,我一般列成一张表放在方法部分:
| 模型 | 数学形式 | 适合的场景 | 最值得调整的参数 |
|---|---|---|---|
| LinearRegression | 最小二乘 | 特征少、共线性弱 | fit_intercept(默认 True) |
| Ridge | 最小二乘 + L2 惩罚 | 特征多、共线性明显 | alpha,通常 0.1 到 10 |
| SGDRegressor | 梯度下降 | 数据量几十万以上 | learning_rate、max_iter |
线性回归在这个项目里不是"最简单的凑数模型",而是"baseline 必须稳"的选择。Ridge 的存在是为了在文档里写出"加 L2 正则后测试集 R² 从 0.86 提到 0.87"这样的对比结论;SGDRegressor 则适合你在源码里额外准备一个大数据量版本,体现工程意识。
参数说明:Ridge 的 alpha 从 0.01、0.1、1、10 各试一遍,画一条 alpha 对 R² 的曲线就够写成一小节实验分析;SGDRegressor 注意三个参数:max_iter 不够或 learning_rate 太大时不收敛,打印 loss 曲线确认,这几个参数也是答辩老师最可能追问的地方。
4.3 评估指标:R²、MAE、RMSE 对应不同评分逻辑
指标这节,我见到的常见误用是只写 R² 一个数。R² 衡量模型解释的方差比例,但它的计算依赖残差平方和,对极端值非常敏感。PM2.5 数据里有沙尘暴这类极端事件时,个别大误差样本会在 R² 里占到很大权重。MAE 是平均绝对误差,单位与真实浓度一致,老师最直接的感受是"我的模型平均偏差 15 微克";RMSE 放大了大误差的惩罚,适合用来暴露极端事件的预测失败。
from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score print("MAE=%.2f" % mean_absolute_error(y_test, y_pred)) print("RMSE=%.2f" % mean_squared_error(y_test, y_pred, squared=False)) print("R2=%.4f" % r2_score(y_test, y_pred)) # MAE 和 RMSE 单位都是 ug/m3,R2 无量纲逻辑说明:mean_squared_error 在较新的 sklearn 版本里用 squared=False 返回 RMSE,如果环境是老版本,写成 np.sqrt(mean_squared_error(...)) 结果一样,但要确认环境版本,并把这个细节留在环境说明里。三个指标最好都打印并写进文档,因为评分标准经常是"MAE 低于某阈值"或"R² 高于某阈值",只报一个数可能直接失分。
参数说明:如果测试集覆盖了一整年,按季度分别算指标会更有说服力:冬天的 R² 通常比夏天低,这是模型在逆温高发期的系统性短板,把它写进"局限与改进"章节反而是加分项。指标计算时注意 y_test 和 y_pred 都必须是 float,sklearn 不接受带 NaN 的数组,评估前再做一次 dropna 双保险。
5. 高分项目避坑指南:5 个让老师一眼识破的常见问题
5.1 数据随机 shuffle,验证集分数虚高到 0.99
现象:测试集 R² 高达 0.98,画预测对比图却明显错位,答辩一追问就露馅。
原因:train_test_split 默认 shuffle=True,模型看到了未来数据,等价于拿答案考试。
解决:按时间索引连续切分,并在文档里写明划分区间。我检查别人源码时,第一眼就找 train_test_split 有没有 shuffle=False,这一步几乎能筛掉一半翻车项目。正确写法和上章一致,切片后顺手打印两段的时间范围,日志放进文档,从根源上杜绝"说不清划分方式"的问题。
5.2 标准化时混入测试集统计量,信息泄漏不自知
现象:R² 偏高但系数解释乱套,把 scaler.mean_ 打出来,发现均值明显不属于训练集分布。
原因:先对整个数据集 fit 了 StandardScaler,再切分训练测试集,测试集的均值方差提前进入了训练过程。
解决:先切分,后 fit;训练集用 fit_transform,测试集只用 transform。这条代码顺序问题是"机器学习中的数据处理"考得最细的点之一,属于那种说出来人人都懂、不说就踩坑的细节。提交前可以在源码里临时加一行 print(scaler.mean_),对照训练集真实均值,确保数字一致。
5.3 只报 R²,被沙尘暴极端值带偏
现象:删掉某一天污染爆表的样本后,R² 从 0.78 跳到 0.92,老师质疑你数据造假。
原因:MSE 和 R² 对极端误差非常敏感,一条 800 微克的异常预测就能把指标拉垮。
解决:同时报 MAE 和 RMSE,极端样本在文档里单独讨论,不要偷偷删除。如果确实删了,必须在数据清洗章节写明删除条件和条数,比如"共删除 3 条浓度超过 900 的传感器异常记录",这种透明度反而会让评分人信任你的处理流程。
5.4 滞后特征边界没写清,被质疑"用了未来数据"
现象:老师看完特征列表质问:你拿 t 时刻的 pm2.5 预测 t+1,这不就是已经知道答案了?
原因:滞后特征和预测步长的关系在文档里没画清楚,评分人默认你泄漏了未来信息。
解决:在方法部分放一张对齐表,写清"特征最大观测时刻为 t,预测目标为 t+1",并强调滞后特征全部来自历史观测。用当前可获得的观测去预测未来,在工作场景里正是常规做法,但文档里不写就是你理亏,一句话加一张表就能避免最尴尬的答辩场景。
5.5 文档与源码参数脱节,复现直接失败
现象:文档写"岭回归 alpha=0.1",代码里根本没有 Ridge,老师按文档去跑直接报错。
原因:报告和代码是两个人写的,或者从模板复制后参数没同步。
解决:每个出现在文档表格里的模型和参数,在源码里必须能找到赋值位置;提交前从零环境跑一遍 main(),把实际输出值填回文档。这条不算加分项,而是拿高分的基本功,很多"高分项目"最后死在复现这一步,太可惜了。
6. 从 85 到 95:残差分析、时间序列交叉验证与可视化叙事
6.1 残差图是文档里性价比最高的一张图
模型跑完别急着写结论,先画残差图。横轴是预测值,纵轴是真实值减预测值,散点如果随机分布在零线上下,说明模型没有系统性偏差;如果出现漏斗形,说明预测值越大误差越分散,这是异方差信号,提示你考虑对目标取 log 再回归。
import matplotlib.pyplot as plt residual = y_test - y_pred plt.scatter(y_pred, residual, s=6, alpha=0.6) plt.axhline(y=0, color="red", linestyle="--") plt.xlabel("Predicted pm2.5") plt.ylabel("Residual") plt.savefig("residual.png", dpi=150)残差图放进作业里,表达的是"我知道模型在哪一类场景失效",这比多调一个参数更能加分。
6.2 用 TimeSeriesSplit 代替单一切分
单一固定切分很容易被质疑"为什么选这个边界"。常见做法是改用 sklearn 的 TimeSeriesSplit,做 5 折交叉验证:每一折训练集在前、测试集在后,最后报的是 5 组指标的均值和标准差。这个数字比单次 0.86 更有说服力,而且几乎不用改代码,把原来的手写切片替换成迭代器即可。
6.3 预测曲线叠加原序列,用图讲清模型边界
最后一张建议放的图是:取测试集前 200 小时,把真实 pm2.5 和预测值画在同一条时间轴上,x 轴用 datetime。这张图能直观看到模型在污染峰值处的滞后——它总是慢半拍。这不是模型的锅,而是滞后特征的固有缺陷,但你能在论文里主动点出"预测峰值存在 1 到 2 小时延迟",评分人会认为你对方法边界有清醒认知,而不是只会报喜不报忧。
我当年交这类作业时,模型分数不差,就是没画残差图丢了文档分,后来每次落地任何预测模型都会先画残差再谈调参。这个习惯一直留到了今天,希望帮到你。
本文还有配套的精品资源,点击获取