最近在帮一个做农业气象服务的小团队设计气温预测方案,目标是利用某市气象站2014—2023年的历史观测数据,提前一天预测次日最高气温。最初我们用线性回归快速跑了一版,误差基本在3℃左右,对于农业霜冻预警来说基本没法用。后来换成随机森林回归,误差直接压到1.4℃以内。这篇文章就把这个模型从数据清洗、特征构造、模型调优到评估落地的完整设计过程整理出来,重点讲清楚每一步为什么这样做,以及实际踩过的几个坑。
1. 气温预测为什么选随机森林:一次需求分析
1.1 业务需求拆解:预测对象与精度目标
在做技术选型之前,先把问题定义清楚。我们要预测的不是"明天大概多少度"这种模糊概念,而是次日最高气温和最低气温两个具体数值。农业场景中风霜冻预警、灌溉调度、作物生长积温计算,都对气温精度有明确要求——误差在1℃以内比较理想,2℃以内还能勉强接受,超过2.5℃基本就失去参考价值了。
从数据形态看,这是一个典型的回归问题:输入是历史气象观测序列和当天部分观测值,输出是连续数值。但气温预测和普通回归任务有区别——时间序列里存在强自相关,今天的温度高度依赖昨天的温度,这种结构特征必须在特征工程里显式建模。
我的需求清单是这样的:
- 只用Python和常见数据科学库实现,团队后续能用Jupyter Notebook直接维护
- 单站预测,不用考虑空间插值、区域网格化,模型规模控制在单机可跑的范围
- 预测时效为24小时,输入是截至当天上午9时的观测数据,输出是次日最高/最低气温
- 模型要可解释,至少要知道哪些气象因子贡献了大,不能是纯黑盒
- 训练和推理速度要快,团队没有GPU资源
1.2 算法对比:为什么不是线性回归、XGBoost或深度学习
线性回归的问题在于气温和气象因子之间的关系远非线性。举例来说,气压与气温的关系在不同天气系统下完全不一样,冬季冷高压过境和夏季暖高压控制下,同样气压值对应的气温可差十几度。湿度与体感温度的关系也受风速影响。线性模型要拟合这种交互效应,需要手动构造大量交叉特征,极容易过拟合。
单一决策树可以自动捕捉非线性关系,但方差太大——训练集略微变化,树结构就完全变了,泛化能力不稳定。
XGBoost/LightGBM在结构化数据上通常比随机森林更强,但代价是超参数数量翻倍,调不好就过拟合,而且对气象数据这种噪声不小的信号,梯度提升的迭代机制容易把异常样本的特征学进去。
深度学习(LSTM、TCN之类)适合长序列依赖,但我们只有9年的逐日数据,总共三千多个样本,深度学习在这个数据量下没有优势,反而容易欠拟合。
随机森林恰好在中间位置:bagging机制天然降低方差,对噪声明感,几乎不需要特征缩放,调参成本低。它由多棵决策树投票决定结果,每棵树用从原始数据中有放回抽样得到的子集训练,并且每次分裂只看一部分随机特征。这样单棵树过拟合没关系,平均之后就能把方差压下来。对三千多个样本的规模来说,随机森林是性价比最高的选择。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据准备:从原始气象记录到可训练的数据集
2.1 数据来源与字段说明
我们用的是某省级气象数据服务接口导出的站点数据,时间跨度2014年1月1日至2023年12月31日,共3652条记录。每条记录包含以下字段:
| 字段名 | 说明 | 单位 |
|---|---|---|
| date | 日期 | YYYY-MM-DD |
| temp_max | 当日最高气温 | ℃ |
| temp_min | 当日最低气温 | ℃ |
| temp_avg | 当日平均气温 | ℃ |
| humidity | 日平均相对湿度 | % |
| pressure | 日平均海平面气压 | hPa |
| wind_speed | 日平均风速 | m/s |
| precipitation | 日降水量 | mm |
| sunshine | 日照时数 | h |
这些字段中,temp_max和temp_min是预测目标,其余作为候选特征。比较理想的情况是还能拿到当天上午9时的实时观测值(9时气温、9时湿度、9时气压),因为短时观测对次日温度的指示性比日均值更强。我们后来补了这部分数据,但前一版本没有,这里先按日均值来设计。
2.2 缺失值和异常值的处理逻辑
气象数据看着规整,实际脏得很。3652条记录里,temp_max字段缺失28条,humidity缺失37条,precipitation缺失51条。处理缺失值我分两种情况:
第一种是连续小段缺失,比如连续两三天观测设备故障,这种情况用pandas的interpolate做线性插值就够了。因为气温是连续变化的,相邻日期的值有强相关性,插值不会引入太大偏差。需要留意的是插值只适合小段缺失,如果连续缺了一个月的,插值出来的数据基本是心理安慰,不如直接删掉这一段。
第二种是单点零星缺失,用前三天同字段的滑动均值填充,或者直接用该日期前后各三天的中位数填充。中位数比均值更稳,不受极端气温的影响。
python复制import pandas as pd
import numpy as np
df['temp_max'] = df['temp_max'].interpolate(method='linear', limit_direction='both')
df['humidity'] = df['humidity'].fillna(df['humidity'].rolling(7, center=True, min_periods=1).median())
异常值处理这里要特别小心。我一开始用3σ原则扫了一遍,结果把连续高温热浪的那几天全标记为异常值了。原因是热浪期间最高温会连续多日偏离均值,但这是正常的气象过程,不是设备故障。后来改用气象学极值约束:某地历史最高气温记录是41.5℃,只要没有超过42℃,再热都算正常。超出气候极值范围的数据才需要进一步核实。
python复制# 基于该城市气候极值做约束
climate_bounds = {
'temp_max': (-10, 42),
'temp_min': (-25, 30),
'wind_speed': (0, 40),
'precipitation': (0, 200)
}
for col, (low, high) in climate_bounds.items():
df[col] = df[col].mask((df[col] < low) | (df[col] > high), np.nan)
这一步做完后,再用气象站冗余设备记录(如果站内有自动站和人工观测两套系统)交叉验证,或者和邻近站点的数据比对。有问题的记录做标记,不直接删——因为删除会破坏时间序列的连续性,后续构造滞后特征时索引对不上。
3. 特征工程:气温预测的灵魂不在模型而在特征
3.1 滞后特征与滑动窗口特征
气温序列有很强的持续性,今天的气温对明天有极高的参考价值。对次日最高气温来说,当天最高气温、当天最低气温、当天平均气温是最重要的三个特征。但光有当天还不够,还需要更长时间尺度的趋势信息。
我把三类特征构造出来:
Lag特征(滞后特征):取预测日往前推1天、2天、3天的最高温和最低温。为什么取3天而不是7天?因为气温的自相关随时间衰减较快,用相关系数算一下,当天与前一天的相关系数在0.85以上,与前三天的相关系数就降到0.6左右了,再往前拉边际收益很小,反而增加特征维度。
python复制for lag in [1, 2, 3]:
df[f'temp_max_lag{lag}'] = df['temp_max'].shift(lag)
df[f'temp_min_lag{lag}'] = df['temp_min'].shift(lag)
滑动窗口特征:过去7天最高温的均值、标准差,过去7天最低温的均值。标准差能反映近期气温波动幅度——如果过去一周气温大起大落,说明冷暖空气交替频繁,这种天气背景下气温预测难度大,模型需要感知到这种不确定性。
python复制df['temp_max_7d_mean'] = df['temp_max'].rolling(7).mean()
df['temp_max_7d_std'] = df['temp_max'].rolling(7).std()
df['temp_min_7d_mean'] = df['temp_min'].rolling(7).mean()
滑动窗口也会产生NaN(前6天没有足够的历史数据)。处理方式是直接丢弃前30天的数据作为冷启动舍弃,因为模型训练不需要穷尽所有样本,留出足够干净的数据更重要。
3.2 周期性特征:季节与节气怎么编码
气温预测绕不开季节属性。1月和7月的"30℃"含义截然不同,模型必须知道日期在一年中的位置。最差的方案是直接给月份编号0到11,因为对回归树模型来说,数字编码会暗示3月和4月(3和4)比3月和9月(3和9)关系更近,但事实不是这样——3月和4月确实相邻,3月和9月中间隔着夏季,温度差异更大,数字编码完全没法表达这种"虽然在数值上相邻但实际意义完全不同"的复杂性。
更好的是周期编码:把日期在一年中的第几天(dayofyear)映射到角度,取正弦和余弦两个值。这样1月1日和12月31日虽然dayofyear差很大,但sin和cos值很接近,模型能学到"年末和年初温度相近"这个规律。
python复制df['dayofyear'] = df['date'].dt.dayofyear
df['season_sin'] = np.sin(2 * np.pi * df['dayofyear'] / 365.25)
df['season_cos'] = np.cos(2 * np.pi * df['dayofyear'] / 365.25)
除了季节,我还加了一个"是否高温预警季节"的二值特征(5月到9月标记为1),这种简单的业务规则特征对随机森林的split很有帮助,因为树模型擅长利用二值特征做快速划分。
3.3 特征相关性检查与最终特征表
特征构造完,先跑一遍相关性矩阵,目的是剔除明显冗余的特征。我发现temp_avg、temp_max_lag1、temp_min_lag1之间的相关系数超过0.9,但这不是要删掉谁——它们各自代表不同的气象意义,树模型可以在不同节点分别使用。真正要警惕的是特征泄漏,比如用当天最高温作为特征去预测当天最高温,这种属于作弊特征,建模时必须排查干净。
最终确定的特征列表如下:
| 特征分组 | 特征名 | 说明 |
|---|---|---|
| 当日观测 | humidity, pressure, wind_speed, precipitation | 日均气象要素 |
| 滞后特征 | temp_max_lag1~3, temp_min_lag1~3 | 前1~3天最高/最低温 |
| 滑动窗口 | temp_max_7d_mean, temp_max_7d_std, temp_min_7d_mean | 一周内气温统计 |
| 周期特征 | season_sin, season_cos | 年周期编码 |
| 业务规则 | is_warm_season | 是否暖季(5-9月) |
这里额外提一句,日照时数(sunshine)是个容易被忽略但对气温预测相当有用的特征。它间接反映了天空云量和辐射强度,尤其在春夏季节,日照时数对次日最高气温有明显指示作用。保留这个特征后模型误差又小了一点。
3.4 时间序列划分:不能随机打乱
这是很多初学者写气温预测时最容易翻车的地方。普通机器学习做分类或回归,通常用train_test_split随机划分训练集和测试集,因为样本之间相互独立。但气象数据是按时间排列的,今天的天气和明天的天气高度关联,如果随机打乱,测试集里会出现大量和训练样本相邻日期的数据,模型相当于"偷看"了未来的信息,测试误差会虚低,真实效果一塌糊涂。
我用的是按时间顺序的划分方式:前8年(2014—2021年)做训练集,2022年做验证集,2023年做测试集。特别注意一点,在构造滞后特征时,必须先整体构造完特征再划分,不能在划分之后单独对训练集做shift操作,否则步骤乱掉会导致滞后值从测试集往训练集泄漏。
python复制train_df = df[df['date'] < '2022-01-01']
val_df = df[(df['date'] >= '2022-01-01') & (df['date'] < '2023-01-01')]
test_df = df[df['date'] >= '2023-01-01']
4. 随机森林模型构建与核心参数详解
4.1 基线模型与参数含义
先用sklearn的RandomForestRegressor默认参数跑一版基线。默认参数下模型已经能work,但性能不稳定,需要逐步调整。
python复制from sklearn.ensemble import RandomForestRegressor
from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score
model = RandomForestRegressor(n_estimators=200, random_state=42)
model.fit(X_train, y_train)
随机森林核心参数的理解,我习惯用一个通俗的类比:每棵树就像一个经验不同的老农民,有的更看重大气压变化,有的更关注湿度和露点,有的则靠节气规律判断。随机森林就是把这些老农民召集起来,每人独立判断,最后投票得出结果。单个农民可能判断错,但多数人的共识往往是可靠的。
具体参数含义:
| 参数 | 作用 | 设置经验 |
|---|---|---|
| n_estimators | 决策树的数量 | 不是越大越好,200~500即可,多了纯费算力 |
| max_depth | 单棵树的最大深度 | 限制过拟合,气象数据一般10~20层 |
| min_samples_split | 内部节点再分裂所需最小样本数 | 防止树学到极端个例,建议5~10 |
| min_samples_leaf | 叶子节点最少样本数 | 让叶子不要太小,增强泛化,建议2~5 |
| max_features | 每个节点随机抽样特征数 | 默认sqrt(特征数),高维数据可调 |
| random_state | 随机种子 | 固定,保证结果可复现 |
4.2 一次完整的网格搜索调参过程
调参不是一上来就把所有参数丢进GridSearchCV,那会非常慢。我的策略是分阶段调:先固定树的数量,调树的深度和叶子节点,再回头确认树的数量,最后微调max_features。
第一步,先把n_estimators固定在200,用GridSearchCV搜索max_depth和min_samples_leaf的组合。
python复制from sklearn.model_selection import GridSearchCV
param_grid = {
'max_depth': [10, 15, 20, 25],
'min_samples_leaf': [1, 2, 4, 6]
}
grid = GridSearchCV(
RandomForestRegressor(n_estimators=200, min_samples_split=5, random_state=42),
param_grid=param_grid,
cv=3,
scoring='neg_mean_absolute_error',
n_jobs=-1
)
grid.fit(X_train, y_train)
print(grid.best_params_)
这里有一件事做对了很关键:scoring参数用负的MAE,而不是默认的R²。为什么?R²解释的是"模型解释了百分之多少的方差",但业务上需要知道的是"误差大概几度"。用MAE做评价指标更贴近业务语言。
搜索结果max_depth=15,min_samples_leaf=4效果最好。原因也合理:气象数据有噪声,叶子节点太小会把个别热浪或寒潮样本单独记住,泛化能力下降;深度太大抓的是数据中的特定细节而非普遍规律。
第二步,再用渐进方式调整n_estimators。很多教程说树越多越好,实际上在n_estimators超过600之后,误差曲线基本走平,之后只是白白增加训练时间。我用100到1000做了个测试,300左右的误差和800相比几乎没有差别。
python复制# 手动探测n_estimators的边际收益
for n in [100, 200, 300, 500, 800]:
model = RandomForestRegressor(
n_estimators=n,
max_depth=15,
min_samples_leaf=4,
random_state=42
)
model.fit(X_train, y_train)
y_pred_val = model.predict(X_val)
mae = mean_absolute_error(y_val, y_pred_val)
print(f'n_estimators={n}, val_mae={mae:.3f}')
实测输出:100棵的val_mae是1.38,200棵是1.32,300棵是1.31,500棵是1.30,800棵还是1.30。边际收益从500以后基本为零,所以最终选了300棵,兼顾效果和推理速度。
第三步,max_features从默认的auto(即sqrt(n_features))调整为0.3~0.5之间的比例值。这一步对特征数在20~30之间的数据效果不算明显,但确实能进一步降低树之间的相关性,让bagging的"平均化"收益更大。最终定在0.35。
4.3 模型训练与持久化
最终模型参数确定后,把训练集和验证集合并重新训练一次,再用测试集评估。这样做可以让模型多看到2022年的数据,进一步提升泛化能力。
python复制# 合并训练集和验证集
X_final_train = pd.concat([X_train, X_val])
y_final_train = pd.concat([y_train, y_val])
final_model = RandomForestRegressor(
n_estimators=300,
max_depth=15,
min_samples_leaf=4,
min_samples_split=5,
max_features=0.35,
random_state=42
)
final_model.fit(X_final_train, y_final_train)
import joblib
joblib.dump(final_model, 'rf_temp_max_model.joblib')
训练时间长不长?300棵树、2400条训练样本、24个特征,8个逻辑核心并行下耗时12秒左右。这也是随机森林比深度学习友好的地方——没有GPU也能快速迭代。
5. 模型评估与预测结果解读
5.1 评估指标:MAE、RMSE、R²各自说明什么
测试集(2023年全年365天)上的结果为:
| 指标 | 数值 |
|---|---|
| MAE | 1.32℃ |
| RMSE | 1.78℃ |
| R² | 0.93 |
三个指标结合起来看而不是只看一个。MAE代表平均绝对误差,意思是平均每天预测值与实际值差1.32度,这是业务上最直观的参考。RMSE对误差大的样本更敏感,如果某天预测差了5度,RMSE会被这个点拉高很多,所以RMSE比MAE大不少,说明存在少数"特别离谱"的日子。R²接近0.93说明模型解释了93%的气温变化方差,在统计模型里属于相当好的水平。
从业务角度看,1.32℃的MAE可以支撑霜冻预警(阈值通常在2℃以上才做决策),但如果要做精细的作物生长模型输入,还是偏粗。后续如果能加入当天的实时探空数据或者数值天气预报场作为辅助输入,还能进一步压缩到1℃以内。
5.2 残差分析:哪些情境下预测最容易失效
我习惯把预测值和真实值画在一张图上看整体跟随效果,但真正能发现问题的是残差图——把每个样本的(真实值 - 预测值)画出来,按日期排列。
用2023年测试集做残差分析,发现几个明显的规律:
极端高温日系统性低估。全年最热的10天(气温超过35℃)里,有7天模型预测偏低超过2℃。原因是训练集里35℃以上的极端高温样本本来就少,模型没见过太多,倾向把预测值往历史均值方向收缩。这是回归模型的通病,对罕见事件天然保守。
寒潮爆发日误差大。某次强冷空气南下,48小时内气温骤降12℃,模型预测值只降了7℃,误差达到5.2℃。原因在于这种剧烈的天气过程主要由大气环流变化驱动,仅靠历史观测序列很难提前捕捉——前一天的数据还没有显示出降温的迹象,模型自然反应不过来。
春秋过渡季误差偏大。3月和11月是气温变化最不稳定的时期,一天之内回暖或降温幅度大,残差的标准差明显高于夏季。这和季节特征编码有关系,过渡季节的日序位置在正弦函数上处于斜率较大的阶段,模型对精确日期较敏感。
这些分析不是为了展示模型缺陷,而是说明接下来优化的方向:要么给极端事件加权重,要么引入外部气象预报数据。单纯在随机森林内部调参解决不了这些问题,要调整特征输入。
5.3 特征重要性的解读方式
随机森林自带feature_importances_,输出每个特征对预测的贡献占比。排序前几位的特征是:
| 排序 | 特征 | 重要性 |
|---|---|---|
| 1 | temp_max_lag1(前日最高温) | 0.28 |
| 2 | temp_max_7d_mean(一周平均最高温) | 0.15 |
| 3 | season_cos(季节余弦编码) | 0.12 |
| 4 | temp_min_lag1(前日最低温) | 0.10 |
| 5 | pressure(气压) | 0.08 |
这个结果和气象常识完全吻合。前日最高温排第一说明气温的持续性是最强的信号;一周平均温代表近期气候背景;季节编码告诉模型现在处于一年中的什么位置;气压变化则反映了天气系统的影响。
有个点要提醒:特征重要性排名反映的是相关性,不是因果性。比如humidity(湿度)的重要性排名靠后,不代表湿度对气温没用,而是因为湿度的信息和气压、降水等特征高度重叠,树模型随机选特征时这部分信息被其他特征接住了。做业务汇报时可以解释为"模型主要通过温度历史、季节位置和气压系统来推断气温",但不要下结论说"湿度不重要"。
6. 实操中的坑与经验总结
6.1 时间泄漏:最隐蔽也最致命的错误
前文提到的时间划分问题,实际项目中还有更隐蔽的泄漏形式。我之前犯过一个错:构造特征时用了shift(-1)这种代码,本来想做"未来一天的数值"作为特征,结果不小心把预测目标本身的次日值也shift进了训练集。模型在训练时看到了未来的最高温,测试时这个特征不存在,导致验证集上MAE只有0.3℃看起来完美,真正上线却是1.4℃。
排查方法很笨但有效:把所有特征名过一遍,凡是特征值里有和目标变量同一天同一来源数据的,必须确认是不是经过了合法的滞后变换。最高温、最低温这两个目标字段绝对不能以原始形式出现在特征里,只能以滞后n天的形式出现。
6.2 数据对齐:站点时间尺度不一致
如果做的是多站点数据,站点之间上报时间可能不统一。有的站每天20时结算日值,有的站是0时结算,日最高温对应的日期可能错位一天。这种问题不仔细看数据说明根本发现不了,但对模型影响很大——你会用今天的最高温和明天标签训练,看起来相关但实际上是"未来"信息。
单站数据也一样要确认:气象站的"日最高温"是前一天的20时至当天20时统计,还是0时至24时统计?不同口径下滞后特征的含义完全不同。拿到数据先搞清楚统计口径,再写特征构造代码。
6.3 随机森林不是万能药:适用边界要清楚
随机森林对中等规模表格数据效果很好,但有三个明显的边界:
对高维稀疏特征不友好。比如把站点ID做独热编码变成几百列,随机森林每次分裂只看少量的随机特征,很容易选到无关的稀疏列,模型退化严重。这种情况更适合用带正则化的线性模型或嵌入方法。
不能外推。随机森林的预测值是训练集叶子节点的均值,意味着它永远不会预测出超出训练数据范围的数值。假设训练集里从来没出现过38℃以上高温,模型预测的极端高温就一定低于某个值。这是随机森林的固有天花板。
对隐式序列关系表达有限。如果做的是小时级气温预测,序列长度上百步,随机森林需要人为构造大量的滑动窗口特征才能勉强模拟时间依赖,效率和效果都不如专门的时序模型。日级预测是随机森林的优势区间,小时级请绕道。
6.4 从日级到多步预测的扩展思路
这个模型目前做的是步长为1的预测,即用截至今天的数据预测明天的气温。如果业务需要预测未来3天甚至7天的最高气温,就需要迭代策略:先用模型预测明天的值,然后把预测值作为新的"前日最高温"特征,继续喂给模型预测后天,逐日滚动。这种方式误差会逐日累积,一般预测第3天误差就会扩大到2℃左右。
另一个方案是构建一个直接多输出回归的随机森林,把未来3天的最高温作为三个目标变量一起建模。sklearn的RandomForestRegressor原生支持多输出,训练时每个目标分别建树,但是共享相同的特征分裂逻辑。实测效果比滚动迭代稍好,因为模型能看到不同步长目标间的相关性,缺点是灵活性降低,不能自由预测任意步长。
我自己的部署方案是两者结合:短期(1~3天)用直接多输出模型,中期(4~7天)用滚动迭代模型,最后在boling层做加权融合。如果后续能把数值天气预报的格点预报值作为外生特征加进来,多步预测的精度还能提升一个档次。
这个项目的完整代码已经整理好放在团队的GitLab上,核心文件就是数据预处理脚本、特征工程模块和模型训练脚本三个。整个过程中最值钱的经验就是:气温预测这类强自相关问题,80%的效果提升来自特征构造和数据质量,模型的边际贡献其实很有限。先把滞后特征、周期特征、滑动窗口特征做扎实,后面替换任何模型(线性回归、XGBoost、LSTM)都不会表现太差;反过来特征没做好,换再高级的算法也是白搭。
