简介:这份资源是面向计算机相关专业学生的机器学习大作业完整源码,以线性回归为核心方法完成PM2.5浓度预测任务,适合正在准备课程设计、期末大作业或需要项目实战练习的学习者使用。项目经导师指导并认可通过,可作为高分作业参考模板,帮助读者快速理解从数据到建模的完整流程。压缩包共18个文件,约2.4MB,包含12个csv数据文件、3个py源码文件、1个npy模型文件、1个png图片及测试相关文件,覆盖训练集、测试集、预测结果与评估脚本等环节,结构清晰便于按模块查阅。目前已有1069人学习下载,说明该方案在同类作业中具有较高参考价值。读者可据此掌握线性回归建模思路、数据预处理与结果评估方法,并对照源码完成自己的预测项目,减少从零搭建的时间成本。
1. 从一份 95 分大作业说起:线性回归做 PM2.5 预测到底在做什么
很多同学搜「机器学习大作业-基于线性回归的PM2.5预测项目python源码」的时候,心里其实有两个诉求:一是赶紧交上一份能拿高分的作业,二是别被老师一问三不知。我当年第一次做这个题,也是先去找现成源码,结果跑通了却讲不出为什么用线性回归、特征怎么选、R² 为什么只有 0.6。后来带学弟学妹做了几轮,才把这条链路摸清楚:PM2.5 预测本质是一个多变量回归问题,用历史气象和污染物数据去拟合下一时刻的 PM2.5 浓度。线性回归之所以常被选作大作业基线,不是因为它最强,而是因为它可解释、可推导、可手写梯度下降,老师能从公式一路问到代码。这篇笔记就按「数据怎么来 → 特征怎么造 → 模型怎么训 → 指标怎么读 → 坑在哪」的顺序,把一份能上 95 分的方案拆开讲,新手能照着复现,熟手能拿去改造成 Ridge、Lasso 或者加多项式特征。适合正在做机器学习期末项目、想搞懂线性回归落地细节的人。
2. 数据与特征工程:PM2.5 预测的输入到底长什么样
2.1 先搞清楚数据集结构和字段含义
常见的 PM2.5 数据集(比如经典的北京 PM2.5 数据)通常是逐小时记录,字段包括 pm2.5、pm10、SO2、NO2、CO、O3 六种污染物,外加 TEMP、PRES、DEWP、RAIN、wd、WSPM 六个气象字段,还有 year、month、day、hour 四个时间字段。做线性回归之前,第一件事不是写模型,而是把数据读进来、看缺失、看分布。很多人直接dropna()完事,结果样本从 4 万掉到 3 万,模型在测试集上表现忽好忽坏,这就是没做缺失分析的血泪经验。
import pandas as pd import numpy as np # 读取数据,注意编码和列名 df = pd.read_csv('PRSA_Data_Aotizhongxin_20130301-20170228.csv') print(df.shape) print(df.isnull().sum().sort_values(ascending=False).head(10)) # 查看 PM2.5 的分布,判断是否需要处理异常值 print(df['PM2.5'].describe()) print((df['PM2.5'] == 0).sum()) # 0 值往往代表缺失,不是真实浓度这段代码先看形状和缺失,再看 PM2.5 的统计量。参数上,isnull().sum()按列统计缺失数量,describe()给出均值、四分位数和极值。逻辑上,如果 PM2.5 缺失比例超过 10%,直接删行会损失太多样本,常见做法是用前后时刻插值或者用同站其他污染物做 KNN 填充。注意 PM2.5 等于 0 的记录要警惕,传感器故障或校准期会产生大量 0,直接当真实值喂给模型,回归线会被拉偏。
2.2 时间特征和滞后特征怎么造
线性回归本身不会自动理解「小时」和「月份」的周期性,所以要把时间拆成可用的数值特征。更关键的是滞后特征:当前时刻的 PM2.5 和前一小时、前两小时高度相关,把 lag1、lag2 加进去,R² 通常能从 0.6 提到 0.8 以上。这是这个项目最容易拉开分数的地方,也是老师最爱问的点。
# 时间特征 df['hour_sin'] = np.sin(2 * np.pi * df['hour'] / 24) df['hour_cos'] = np.cos(2 * np.pi * df['hour'] / 24) df['month_sin'] = np.sin(2 * np.pi * df['month'] / 12) df['month_cos'] = np.cos(2 * np.pi * df['month'] / 12) # 滞后特征:用前一小时、前两小时的 PM2.5 df['PM2.5_lag1'] = df['PM2.5'].shift(1) df['PM2.5_lag2'] = df['PM2.5'].shift(2) # 风向 one-hot df = pd.get_dummies(df, columns=['wd'], prefix='wd') # 构造完滞后特征后再删缺失,避免误删 df = df.dropna(subset=['PM2.5', 'PM2.5_lag1', 'PM2.5_lag2'])这里用 sin/cos 编码小时和月份,是为了保留周期性又不引入 23 和 0 之间的虚假距离。shift(1)生成前一小时的值,注意必须在删缺失之前做,否则第一行会变成 NaN。风向用 one-hot,是因为它是类别变量,直接映射成数字会让模型误以为北风和东风有大小关系。参数上,lag 阶数不是越多越好,加到 lag3、lag4 后共线性会变严重,VIF 飙升,系数解释性下降,一般 lag1 和 lag2 就够。
2.3 特征标准化和训练集划分的先后顺序
线性回归用梯度下降求解时,特征量纲差异大会导致收敛慢甚至震荡。PM2.5 数值在几十到几百,而 hour_sin 在 -1 到 1,不做标准化,学习率很难调。但标准化必须先划分训练测试集,再在训练集上 fit,否则测试集信息泄漏,分数虚高,答辩时被问到就露馅。
from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler feature_cols = [c for c in df.columns if c not in ['PM2.5', 'date']] X = df[feature_cols].values y = df['PM2.5'].values X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42, shuffle=False ) scaler = StandardScaler() X_train = scaler.fit_transform(X_train) X_test = scaler.transform(X_test)shuffle=False是时间序列的常见做法,保持时间顺序,避免用未来数据预测过去。fit_transform只在训练集上调用,测试集用transform,这是标准流程。如果数据量不大,也可以按时间切分,比如前 80% 做训练、后 20% 做测试,更贴近真实预测场景。特征列里要排除目标本身和日期字符串,否则会报类型错误。
3. 线性回归模型:从手写梯度下降到 sklearn 调参
3.1 手写批量梯度下降,把公式变成代码
大作业如果只调LinearRegression().fit(),分数可能不低,但老师一问「梯度怎么推的」就尴尬。稳妥做法是手写一版批量梯度下降,再和 sklearn 结果对比。线性回归假设是 h(x) = θ₀ + θ₁x₁ + … + θₙxₙ,损失函数是均方误差,梯度更新公式为 θ = θ - α * (2/m) * Xᵀ(Xθ - y)。
def gradient_descent(X, y, lr=0.01, epochs=1000): m, n = X.shape X_b = np.c_[np.ones((m, 1)), X] # 加偏置列 theta = np.zeros(n + 1) loss_history = [] for i in range(epochs): y_pred = X_b.dot(theta) error = y_pred - y grad = (2 / m) * X_b.T.dot(error) theta -= lr * grad loss = np.mean(error ** 2) loss_history.append(loss) return theta, loss_history theta, losses = gradient_descent(X_train, y_train, lr=0.01, epochs=1000)np.c_[np.ones((m,1)), X]给特征矩阵加一列全 1,对应偏置项 θ₀。学习率 lr 取 0.01 是经验值,太大容易发散,太小收敛慢。epochs 设 1000 后看 loss_history 是否还在下降,如果早就平了就减,如果还在降就加。这段代码的价值在于你能画出损失曲线,答辩时直接展示「模型确实在收敛」,比空口说强得多。
3.2 sklearn 的 LinearRegression 和正则化版本怎么选
手写版用来讲原理,实际出分还是用 sklearn 更稳。LinearRegression用最小二乘闭式解,速度快;如果特征多、共线性强,换成Ridge或Lasso更合适。Ridge 加 L2 正则,系数收缩但不为 0;Lasso 加 L1 正则,能把不重要特征系数压到 0,顺便做特征选择。
from sklearn.linear_model import LinearRegression, Ridge, Lasso from sklearn.metrics import mean_squared_error, r2_score, mean_absolute_error models = { 'OLS': LinearRegression(), 'Ridge': Ridge(alpha=1.0), 'Lasso': Lasso(alpha=0.1) } for name, model in models.items(): model.fit(X_train, y_train) pred = model.predict(X_test) print(name, 'RMSE:', np.sqrt(mean_squared_error(y_test, pred)), 'MAE:', mean_absolute_error(y_test, pred), 'R2:', r2_score(y_test, pred))alpha是正则强度,Ridge 的 alpha 越大惩罚越重,一般从 0.1、1、10 里试。Lasso 的 alpha 取 0.1 时如果很多系数变 0,说明特征冗余。评价指标里 RMSE 和 MAE 单位是 μg/m³,R² 看拟合优度。注意 RMSE 对异常值敏感,MAE 更稳健,两个都报更专业。如果 Ridge 比 OLS 好,说明有共线性;如果 Lasso 稀疏后 R² 掉太多,说明被压掉的特征其实有用。
3.3 用交叉验证挑超参数,别只切一次测试集
单次 train_test_split 的分数波动可能很大,换个 random_state 就差几个点。用 K 折交叉验证能给出更稳的估计,也方便选 Ridge/Lasso 的 alpha。
from sklearn.model_selection import GridSearchCV param_grid = {'alpha': [0.01, 0.1, 1, 10, 100]} grid = GridSearchCV(Ridge(), param_grid, cv=5, scoring='neg_root_mean_squared_error') grid.fit(X_train, y_train) print('Best alpha:', grid.best_params_) print('Best CV RMSE:', -grid.best_score_)cv=5表示 5 折,scoring用负 RMSE 是因为 sklearn 的评分函数越大越好。best_params_给出最优 alpha,best_score_是交叉验证平均分。注意时间序列严格来说要用TimeSeriesSplit,普通 KFold 会打乱时间顺序,导致用未来预测过去,分数偏乐观。如果老师不深究,KFold 也能用,但自己心里要清楚这个边界。
4. 结果评估与可视化:让 95 分有据可依
4.1 残差分析和预测对比图怎么画
光报 R² 不够,老师想看的是你懂不懂模型哪里错了。残差图能暴露系统性偏差:如果残差随预测值增大而扩散,说明异方差;如果残差有周期性,说明时间特征没提干净。
import matplotlib.pyplot as plt pred = models['Ridge'].predict(X_test) residuals = y_test - pred fig, axes = plt.subplots(1, 2, figsize=(12, 4)) axes[0].scatter(pred, residuals, alpha=0.3) axes[0].axhline(0, color='red', linestyle='--') axes[0].set_xlabel('Predicted PM2.5') axes[0].set_ylabel('Residual') axes[1].plot(y_test[:200], label='True') axes[1].plot(pred[:200], label='Predicted') axes[1].legend() plt.show()左图残差应该围绕 0 随机分布,如果呈喇叭形,考虑对 PM2.5 取对数再回归。右图取前 200 个点看趋势,预测线如果总是滞后真实线,说明 lag 特征还不够或者模型欠拟合。这两张图放进报告,比单纯堆指标有说服力。
4.2 特征重要性怎么解释
线性回归的系数就是特征重要性,但必须在标准化之后比较,否则量纲不同没法比。标准化后的系数绝对值越大,说明该特征对 PM2.5 影响越大。
coef_df = pd.DataFrame({ 'feature': feature_cols, 'coef': models['Ridge'].coef_ }).sort_values('coef', key=abs, ascending=False) print(coef_df.head(10))通常 PM2.5_lag1 系数最大,符合直觉;CO、NO2 次之,因为它们是燃烧排放的伴随物;风速 WSPM 系数为负,风越大扩散越好。如果出现某个特征系数符号反直觉,先查共线性,再看是否标准化漏了。这份系数表是答辩时解释「模型学到了什么」的核心材料。
5. 避坑与排查:这份大作业最容易翻车的五个地方
5.1 现象:R² 高得离谱,超过 0.98
原因:把 PM2.5 的当前值或未来值泄漏进了特征,比如 lag 特征用了shift(-1),或者标准化时用了全量数据。 解决:检查所有特征的时间戳,确保预测 t 时刻只用 t 之前的信息;标准化严格在训练集 fit。
5.2 现象:模型跑出来 RMSE 几百,比均值预测还差
原因:学习率太大导致梯度爆炸,或者特征没标准化,量纲差异让梯度下降震荡。 解决:把 lr 降到 0.001 试,确认 loss 曲线单调下降;所有数值特征做 StandardScaler。
5.3 现象:换了 random_state,R² 波动超过 0.1
原因:数据量小或者测试集划分不均匀,单次划分偶然性大。 解决:改用 5 折交叉验证报平均分和标准差,或者用时间序列切分,报告里写清楚划分方式。
5.4 现象:PM2.5 等于 0 的记录很多,模型预测偏低
原因:0 值多为传感器缺失或校准期,不是真实浓度,直接参与训练拉低了回归线。 解决:把 0 值替换为 NaN 再插值,或者只保留 PM2.5 大于 0 的样本,并在报告里说明处理依据。
5.5 现象:答辩时被问「为什么不用神经网络」答不上来
原因:只背了代码,没想过选型理由。 解决:准备一句话——线性回归作为基线可解释、训练快、能给出系数方向,后续可以在此基础上加多项式特征或换树模型对比,本项目重点在完整流程和特征工程。
6. 进阶技巧:把线性回归大作业做出区分度
如果只做到上面这些,拿 85 分没问题,想冲 95 以上,得在「对比」和「解释」上多走一步。我一般会加两个动作:一是构造多项式特征和交互项,看 R² 有没有提升,同时观察是否过拟合;二是用 statsmodels 输出完整回归报告,把 p 值和置信区间放进去,让统计味更足。
import statsmodels.api as sm X_train_sm = sm.add_constant(X_train) model_sm = sm.OLS(y_train, X_train_sm).fit() print(model_sm.summary())summary()会给出每个特征的系数、标准误、t 值和 p 值。p 值小于 0.05 说明该特征统计显著,置信区间不跨 0 说明方向稳定。这份表放进报告,比 sklearn 的默认输出更专业。注意 statsmodels 对共线性敏感,如果 VIF 超过 10,先删掉冗余特征再跑。
另一个技巧是做分季节建模。PM2.5 在冬季和夏季的成因不同,冬季取暖排放多,夏季光化学反应强。把数据按月份拆成采暖季和非采暖季分别训练,对比系数变化,能讲出「同一特征在不同季节作用不同」的故事。这个分析不复杂,但能让老师看到你在思考数据背后的物理意义,而不是机械调包。
df['season'] = df['month'].apply(lambda m: 'winter' if m in [11,12,1,2,3] else 'other') for s in ['winter', 'other']: sub = df[df['season'] == s] X_s = sub[feature_cols].values y_s = sub['PM2.5'].values X_tr, X_te, y_tr, y_te = train_test_split(X_s, y_s, test_size=0.2, random_state=42) sc = StandardScaler() X_tr = sc.fit_transform(X_tr) X_te = sc.transform(X_te) m = Ridge(alpha=1.0).fit(X_tr, y_tr) print(s, 'R2:', r2_score(y_te, m.predict(X_te)))这段代码按季节分组后重新标准化和训练,对比两组 R² 和系数。如果冬季 R² 明显高,说明冬季 PM2.5 更依赖滞后浓度和气象条件;夏季低,可能是光化学反应等非线性因素没被线性模型捕捉,这正好是你讨论模型局限性的素材。
最后说个习惯:我每次交大作业前,都会把代码从干净环境重跑一遍,确认没有依赖本地缓存文件,随机种子固定,输出结果和报告里写的一致。这个动作救过我两次,一次是忘了固定 random_state,一次是测试集路径写成了绝对路径。希望帮到你。
本文还有配套的精品资源,点击获取