
说起机器学习课的经典作业预测PM2.5绝对是绕不开的一道坎。这学期某高校的机器学习课程就留了这么一份作业不用任何现成的机器学习库纯手写代码用历史空气监测数据去预测PM2.5浓度。起初以为只是练手线性回归真正动手才发现从数据清洗、特征工程到梯度下降的每一步推导全都自己来的时候很多藏在调库背后的细节全都暴露出来了。这篇文章就完整记录我是怎么一步步做这个作业的包括数据预处理、手写梯度下降、调参技巧和踩过的坑无论你是在做同类作业还是想巩固ML基础都有实打实的参考价值。1. 任务拆解预测PM2.5到底在解决什么1.1 从业务问题到回归问题很多同学拿到这种题目第一反应是直接用sklearn不就行了但作业明确要求手写代码那我们就得先把问题本身想透。PM2.5浓度预测本质上是一个监督学习里的回归问题给定若干历史时刻的观测数据学习一个映射函数输出未来某个时刻的PM2.5数值。具体到这份经典作业任务描述一般是这样的某地区环保部门提供了全年逐小时的空气质量监测数据包含PM2.5、PM10、SO2、NO2、CO、O3、风速、降雨量等多项指标目标是利用前9个小时的所有监测数据去预测第10个小时的PM2.5浓度。这里的关键点在于输入不是单一的PM2.5历史序列而是多个特征维度的历史窗口。也就是说每个训练样本的输入是一个维度为特征数乘以9的向量输出是一个连续的实数值。这跟我们平时理解的用前一天温度预测今天温度不太一样它是多变量时间序列预测的一种简化形式。1.2 数据集结构与样本构建我拿到的原始数据大概是这样的格式每一行是某个小时的全部监测指标一天24个时刻一个月约30天一年12个月加起来6000多行。字段有18个左右包括各种污染物浓度、气象因子等。构建训练样本时需要注意不能把整个文件直接喂给模型。标准做法是按小时滑动窗口切分从第1个小时到第9个小时作为第一个样本的输入特征第10个小时的PM2.5作为标签然后窗口向后滑动一小时即第2到第10小时作为输入第11小时的PM2.5作为标签。这样每个完整样本覆盖10小时的数据。注意切分时一定要按月份边界处理。如果跨月切分比如把上个月最后几小时和下个月前几小时拼在一起会造成样本之间的时间连续性被破坏模型评估时也会出现数据泄漏的风险。1.3 评估指标RMSE还是MAE评估回归模型最常用的两个指标是RMSE均方根误差和MAE平均绝对误差。这个作业里推荐用RMSE原因有两个RMSE对大误差更敏感PM2.5预测中我们更不希望出现某个时刻严重低估的情况这直接关系到空气污染预警的准确性。RMSE和损失函数MSE同源训练过程中观察loss曲线和验证集RMSE的变化趋势更加匹配。计算公式很简单RMSE sqrt( (1/N) * Σ(y_i - y_pred_i)^2 )我自己在做的时候会同时打印这两个指标因为如果RMSE和MAE差距很大说明预测结果中存在一些比较大的离群误差这时候就要检查是不是某些极端污染事件没有被模型捕捉到。2. 手写线性回归为什么值得从零开始2.1 手写代码的价值很多初学者会有疑问明明一个LinearRegression函数就能搞定的事为什么非要自己写我想说手写一遍线性回归胜过调库十遍。原因是当你自己实现从矩阵求导到参数更新的每一步时才会真正理解机器学习算法的本质。具体到这份作业自己手写代码通常意味着不能直接调用sklearn的LinearRegression、SGDRegressor等现成模型需要自己实现损失函数、梯度计算、参数更新循环可以借助numpy做矩阵运算但算法逻辑必须自己写我实际做下来最大的感悟是手写一遍之后以后再遇到其他模型看文档的速度和理解深度完全不一样。2.2 线性回归模型与损失函数线性回归假设输出是输入的线性组合模型形式为y_pred X * w b其中X是形状为(n_samples, n_features)的输入矩阵w是权重向量b是偏置项。为了代码简洁通常把b合并到w中即在X最后一列加一列全1这样模型就变成y_pred X * w其中X维度是(n_samples, n_features 1)w维度是(n_features 1, 1)。损失函数采用均方误差MSEL(w) (1/N) * Σ(y_i - X_i * w)^2用矩阵形式可以简洁地写成L(w) (1/N) * (y - X*w)^T * (y - X*w)2.3 梯度下降算法的引擎要让损失函数最小化最经典的方法是梯度下降。核心思想很朴素沿着损失函数下降最快的方向即负梯度方向更新参数每次迈出一小步反复迭代直到收敛。参数更新公式w w - lr * ∇L(w)其中lr是学习率∇L(w)是损失函数对参数的梯度。这个作业用的是批量梯度下降Batch Gradient Descent也就是每次更新都用全部训练样本计算梯度。这么做的好处是梯度方向稳定收敛过程平滑缺点是数据量大时单次更新开销较高。不过对这份数据集几千个样本来说完全够用而且手写起来也最直观。3. 数据预处理决定成败的隐藏环节3.1 缺失值处理原始数据集几乎不可能干干净净。我遇到的缺失情况主要有两种某些时刻某个污染物监测值为空数值为0但实际可能是仪器未检出需要结合上下文判断处理缺失值的方法有很多最简单的是前向填充即用前一个小时的数值填充当前缺失值。空气质量变化在短时间内是连续的前向填充对连续缺失的容忍度不错。如果缺失比较多我建议做线性插值效果会更好。千万不能直接把缺失值删掉因为切窗口后一行缺失可能牵连整个样本作废数据浪费很严重。3.2 特征标准化梯度下降的加速器这是整份作业里最容易忽视但又最关键的一步。如果不做标准化直接跑梯度下降你会发现两个问题收敛速度极慢迭代几千次loss还是下不去可能出现梯度爆炸loss变成nan原因是不同特征的量纲差异巨大PM2.5浓度可能是两位数而风速或降雨量可能是个位数甚至小数点后几位。这种情况下损失函数的等高线呈狭长椭圆形梯度下降会在峡谷中来回震荡。标准化的方式我推荐Z-score标准化x (x - mean) / std即对每个特征减去均值除以标准差。注意要用训练集的均值和标准差来标准化测试集不能混用全数据集的统计量否则会造成信息泄漏评估结果虚高。3.3 构建窗口样本踩过的第一个坑实现窗口切分时我最初犯了一个低级错误直接用pandas的shift函数错位来生成特征列。这样确实能产生滞后特征但要生成9个时间步、18个特征的组合数据代码写起来容易混乱。推荐的做法是写一个明确的循环def create_dataset(df, feature_cols, target_col, n_hours9): X, y [], [] for i in range(len(df) - n_hours): X.append(df.iloc[i:in_hours][feature_cols].values.flatten()) y.append(df.iloc[in_hours][target_col]) return np.array(X), np.array(y)这个思路简单直接每个样本把9个小时的18个特征按时间顺序拼接成一个162维的向量。这样做有个额外好处模型可以自动学习不同时刻特征之间的权重关系比如前3小时的PM2.5对当前预测最重要这类规律。4. 核心代码实现从矩阵运算到训练循环4.1 数据加载与划分我拿到了CSV格式的原始数据。先读取并做了简单的探查看字段类型、缺失比例、数值分布。然后按月份切分每个月前20天作为训练集后10天作为测试集。这是这份作业官方推荐的做法可以避免随机切分造成的时间泄漏。import numpy as np import pandas as pd df pd.read_csv(air_quality.csv) # 假设数据列含: PM2.5, PM10, SO2, NO2, CO, O3, WINDSpeed, RAINFALL... feature_cols [col for col in df.columns if col ! PM2.5 and col ! Date] target_col PM2.5 # 按月份划分 months df[Month].unique() train_ids get_train_indices() # 每月前20天 test_ids get_test_indices() # 每月后10天4.2 标准化实现标准化这一步虽然代码很短但是顺序很重要先划分数据再标准化。而且训练集和测试集要用同一套mean和std。def standardize(X_train, X_test): mu np.mean(X_train, axis0) sigma np.std(X_train, axis0) # 防止除零 sigma[sigma 0] 1 X_train_std (X_train - mu) / sigma X_test_std (X_test - mu) / sigma return X_train_std, X_test_std, mu, sigma4.3 手写梯度下降实现核心部分我的实现如下。为了让代码通用性更强我支持在损失函数中加入L2正则化项后面调参的时候会用到。def train_linear_regression(X, y, learning_rate0.01, epochs5000, reg_lambda0.0): n_samples, n_features X.shape # 在X末尾加一列1用于合并偏置项w0 X_b np.hstack([X, np.ones((n_samples, 1))]) # 初始化权重 w np.zeros((n_features 1, 1)) y y.reshape(-1, 1) loss_history [] for epoch in range(epochs): # 前向传播 y_pred np.dot(X_b, w) # 计算损失带L2正则 mse np.mean((y_pred - y) ** 2) reg_term reg_lambda * np.sum(w[:-1] ** 2) loss mse reg_term # 梯度计算 grad (2 / n_samples) * np.dot(X_b.T, (y_pred - y)) # 正则化梯度不惩罚偏置项 if reg_lambda 0: grad[:-1] 2 * reg_lambda * w[:-1] # 参数更新 w - learning_rate * grad if epoch % 500 0: loss_history.append(loss) print(fEpoch {epoch}, loss: {loss:.4f}) return w, loss_history这里的每一步都是手写推导的产物。比如梯度公式我用矩阵求导得到∇L(w) (2/N) * X_b^T * (X_b * w - y)代码和数学表达式完全对应这也是手写代码最容易验证正确性的方式。4.4 预测与RMSE评估训练完成后预测流程和训练前向传播一样但要注意用相同的标准化参数处理测试集。def predict(X_test, w, mu, sigma): X_test_std (X_test - mu) / sigma X_b np.hstack([X_test_std, np.ones((X_test.shape[0], 1))]) return np.dot(X_b, w) # 计算RMSE def rmse(y_true, y_pred): return float(np.sqrt(np.mean((y_true - y_pred) ** 2)))我最初跑通整个流程后训练集RMSE大约在6到8之间测试集RMSE在9到12之间。这个水平是合理基线后续调优应该能继续压低。5. 调参实战学习率、正则化与优化策略5.1 学习率的选择从爆炸到收敛的摸索学习率是手写梯度下降最重要的超参数。我试过几个典型值效果天差地别学习率现象结论1.0loss直接变nan过大发散0.1前几步loss剧烈震荡后逐渐收敛偏大但可用0.01收敛平稳800次左右进入平台期合适的起点0.001收敛很慢3000次后仍在下降偏小需增加epochs特别说明一下学习率取1.0时出现nan原因很直接标准化后的特征量级在1附近但162个特征叠加后梯度很大参数更新步子迈得太大直接跳过最优点导致发散。遇到这种情况不要慌把学习率降一个数量级再试。我最终选的是learning_rate0.05配合5000次迭代。如果从loss曲线观察前500次loss急剧下降1500次后趋于平缓这符合线性回归的典型收敛特征。5.2 正则化防止过拟合的保险丝刚开始跑的时候我发现训练集RMSE比测试集低不少这是过拟合的信号。解决办法是加L2正则化岭回归在损失函数后面加一个权重平方和项L_reg MSE λ * Σw^2这样做的本质是惩罚过大的权重让模型不要过度依赖某个特征或某个时间步。我用reg_lambda0.1到1.0之间做了几个实验lambda训练RMSE测试RMSE06.810.20.16.99.40.57.39.11.07.89.3结论很明显加了正则化后训练误差略微上升但测试误差下降了近1个点模型泛化能力更好。lambda0.5左右是甜点区。这就是用一点点训练精度换取更强的泛化能力实际项目中非常常见的取舍。5.3 增加多项式特征把线性模型变强纯粹的线性模型有天花板因为PM2.5与各监测指标之间未必是纯线性关系。一个简单有效的增强手段是加入特征的平方项和交互项。我试过加入每个特征的平方项后输入维度从162变成了324。维度翻倍但模型表达能力更强了。测试结果也确实证明了这一点不加平方项时测试RMSE约9.3加了平方项后降到8.4左右。代价是训练时间变长但数据量小完全可以接受。提示加多项式特征后标准化和正则化变得更加重要。高次项的存在让特征分布更分散梯度更容易爆炸。6. 踩坑记录与背后的原因6.1 坑一测试集标准化用错了参数这是我踩过最隐蔽的坑。最初我在切完数据后直接对所有数据做标准化然后用同样方法处理测试集。结果测试RMSE异常低我一度以为自己调参非常成功。后来重新审视代码才发现这是典型的数据泄漏——测试集的统计信息被泄露给了模型验证结果失去意义。正确的做法必须牢记只能计算训练集的mean和std然后把它应用到测试集。我最后把这个逻辑写成了标准流程的一部分双保险确保以后不再犯。6.2 坑二梯度算错了但loss还在下降这个问题更微妙。我有一次不小心在梯度计算里多乘了一个样本数因子修改后发现loss确实在下降但速度特别慢而且最终的RMSE明显偏高。这是因为如果梯度的方向是正确的只是大小不对梯度下降依然会向着最优方向前进只不过步长被缩放导致收敛慢。怎么排查用数值梯度验证。手动给权重加一个微小扰动用差分近似梯度然后和解析梯度对比def numerical_gradient(X, y, w, epsilon1e-6): grad np.zeros_like(w) for i in range(len(w)): w_plus w.copy() w_minus w.copy() w_plus[i] epsilon w_minus[i] - epsilon grad[i] (loss(X, y, w_plus) - loss(X, y, w_minus)) / (2 * epsilon) return grad如果解析梯度和数值梯度在一个数量级小于1e-4的误差基本可以确认梯度公式正确。这是我强烈推荐给所有手写ML代码的人的自检方法。6.3 坑三样本切分时把月份边界忽略了最开始我用简单的循环切窗口没有注意每个月之间是连续数据。结果就是每个月的第10天样本可能混入第9天最后几小时和下个月第1天前几小时的数据。虽然训练集和测试集都各自合法但跨月份的样本在现实中并不存在会引入噪声。解决方式是按月份分组对每个月单独切窗口然后丢弃每个月末尾不足9小时的残余数据。每月大约能生成20天*24小时-9个样本损失很小。6.4 坑四RMSE计算时单位不一致还有一个很萌的错误预测结果和真实值明明都是PM2.5浓度但计算RMSE时一个忘了把y从DataFrame的float64转成numpy的ndarray导致维度对不上结果要么报错要么数值异常。这种基础问题也提醒我写代码前先确认数据结构的shape和dtype能省下大量调试时间。7. 经验心得从作业到真实项目的迁移做完这个作业我的收获远不止学会了一个线性回归。有几个认知上的改变我觉得比代码本身更重要。第一手写一次算法比看十遍文档管用。以前我总觉得自己懂梯度下降但实际写的时候才发现连为什么损失函数要除以N这种基础问题都需要重新想清楚。手写代码强迫你把每个细节都搞清楚这种深度理解是调库给不了的。第二数据预处理的坑比模型本身的坑多得多。这个作业里真正让我卡壳最久的不是梯度下降而是标准化和样本切分。现实中做机器学习项目数据清洗构建往往占据60%以上的时间这个作业提前帮我建立了这个意识。第三调参不是玄学是有方法论的。从学习率发散到收敛从过拟合到正则化每一步都有指标和曲线可以观察。关键是建立起控制变量、逐一验证的实验习惯。最后再分享一个小技巧训练过程中记得把loss曲线画出来如果发现loss在某个点突然跳高再降回来多半是学习率太大在最优解附近震荡。把curva图存下来不管是写实验报告还是复盘都很方便。这个作业做完我顺手把代码整理成了一个简单的模块以后再遇到回归问题可以直接套用改改特征列就能用性价比极高。