简介:这份名为 ml_drought 的压缩包面向气候科学、干旱预测与遥感数据处理方向的学习者和研究者,提供一套用于创建、相互比较和评估机器学习方法的端到端管道。它围绕 Copernicus 2019 相关场景,将数据准备、统一训练与测试格式、模型对比和评估等任务拆分为 src 中的不同类,适合具备 Python 基础、希望复现气候机器学习实验的中级读者。包内文件总数显示为 0,类型明细暂未提供,结合标签可见以 Jupyter Notebook 为主要载体,并包含 Python 环境配置内容,压缩包约 49.31MB;描述中提及 Anaconda、environment.yml 与 esowc-drought 环境,可用于搭建可复现的实验环境。已有 142 人学习,读者可从中获得统一数据格式的组织思路、机器学习方法比较与评估流程、项目目录模块划分及环境依赖配置参考,便于在干旱预测课题中建立基线并排查复现问题。
1. 干旱预警慢半拍:一套用 Copernicus 数据跑机器学习预测的 Notebook 长什么样
干旱是最容易被常规气象预警漏掉的一类灾害——它没有登陆时刻,等河道见底、土壤开裂时,损失已经积累了三个月。传统做法是用标准化降水蒸散指数(SPEI)这类统计指标做事后刻画,而机器学习能做的,是把降水、温度、土壤湿度、植被指数这些多源信号拼到一起,做提前一到六个月的滚动预测。这套基于 Copernicus 开放数据、以 Jupyter Notebook 串联的干旱预测项目,正好把这条链路走通了:下载再分析数据、构造滞后特征、对比几类回归模型、把预测值还原成干旱等级图,每一步都能在单元里跑出来看结果。它适合做气候时序分析、遥感数据建模的人,也适合想找一个真实回归场景练手的开发者。真正难的地方不在模型选型,而在数据对齐和特征泄漏这两件事上。
2. 数据管线:把 Copernicus 网格数据整理成一张能喂给模型的表
整条链路里最耗时间的不是调参,是数据。一套能跑通的干旱预测 Notebook,七成代码量都在处理 NetCDF:单位换算、时间重采样、空间裁剪、缺失值标记、滞后对齐。这一步做错,后面模型表现再好也是假的。下面按“定标签、拉数据、造特征、控内存”四步拆开。
2.1 标签定义:为什么用 SPEI 而不是直接用降水距平
很多人第一反应是拿“降水距平”当预测目标,简单直观。但干旱的本质是水分收支不平衡,温度升高会推高潜在蒸散需求,同样的降水量在更热的年份意味着更严重的干旱。SPEI 的计算逻辑是先算月度水分平衡 D = P − PET,再对 D 做多个月累积,然后用三参数 log-logistic 分布拟合,最后把累积值映射到标准正态分布上取值。这样得到的是一个跨地区、跨季节可比的标准化量,均值 0、标准差 1,−1 以下就可以算干旱信号。
时间尺度是第一个必须拍板的参数,不同尺度对应的物理过程完全不同:
| 尺度 | 累积窗口 | 主要响应对象 | 建模适用性 |
|---|---|---|---|
| SPEI-1 | 1 个月 | 气象干旱,短期降水亏缺 | 噪声大,可预测性差,一般不单独用 |
| SPEI-3 | 3 个月 | 农业干旱,土壤水分亏缺 | 预测窗口 1~3 个月时最常用 |
| SPEI-6 | 6 个月 | 农业到水文过渡 | 预测窗口 3~6 个月,样本相关性高 |
| SPEI-12 | 12 个月 | 水文干旱,地下水位、水库 | 序列自相关极强,容易做出虚高精度 |
我一般会把 SPEI-3 作为主标签,SPEI-6 作为辅助验证目标。原因是 SPEI-3 和土壤湿度、植被指数的响应时间对得上,而这些恰好是模型最有效的输入特征;SPEI-12 虽然看起来“更稳”,但它的自相关系数常常在 0.9 以上,模型只要把上一个月的值抄过来就能拿到很漂亮的 RMSE,实际业务价值反而不高。
2.2 拉数据:CDS 请求怎么写才不至于反复排队
再分析数据走 Copernicus 气候数据服务接口,用官方 Python 客户端提交请求。经验是:一次只拉一个变量、只拉目标区域、只拉需要的年份区间。一个请求里塞十几个变量和全球范围,排队时间和文件体积都会成倍上涨,中途断掉还得重来。
import cdsapi client = cdsapi.Client() # 只保留建模真正用得上的四个量,变量越多排队越久 VARIABLES = [ "total_precipitation", # 降水,模型主特征 "2m_temperature", # 气温,用于算距平和 PET "potential_evaporation", # 潜在蒸散,SPEI 计算的另一半 "volumetric_soil_water_layer_1", # 表层土壤体积含水量 ] request = { "product_type": "monthly_averaged_reanalysis", # 月平均产品,适合月度尺度建模 "variable": VARIABLES, "year": [str(y) for y in range(1990, 2022)], # 训练+验证+测试的整体区间 "month": [f"{m:02d}" for m in range(1, 13)], "time": "00:00", "area": [45, 70, 25, 120], # 北纬45 西经70 南纬25 东经120,按目标流域改 "format": "netcdf", } client.retrieve("reanalysis-era5-land-monthly-means", request, "era5_monthly.nc")area的顺序是北、西、南、东,写反了会拿到空文件或者报错。把这个参数按流域外扩 2~3 个格点,既保留了上游来水的信息,又能把数据量压下去一个数量级。product_type和数据集名称要看平台当期的文档核对,不同产品线的命名不一样,直接抄旧脚本很容易在这里卡住。年份区间建议比自己计划的训练区间前后各多留两年,因为滚动特征会吃掉开头的几个月,标准化又会吃掉边界样本。
下载完成后第一件事不是急着建模,而是打印一遍坐标:
import xarray as xr ds = xr.open_dataset("era5_monthly.nc") print(ds.coords["latitude"].values[[0, -1]]) # 看看纬度是递减还是递增 print(ds.coords["longitude"].values[[0, -1]]) # 经度是 0~360 还是 -180~180 print(ds["tp"].attrs.get("units")) # 确认降水单位经度轴在 0~360 和 −180~180 之间来回切是常事,切片方向写反会静默返回一个空数组,dropna()之后得到一张零行表,然后你要花半小时去找哪里出问题。
2.3 从 NetCDF 到特征表:聚合、滞后与单位换算
原始数据是 (time, lat, lon) 的三维网格,模型要的是二维表,一行一个“格点—月份”样本。中间的转换工作包括三块:单位统一、滚动累积、滞后平移。
import numpy as np import pandas as pd import xarray as xr ds = xr.open_dataset("era5_monthly.nc") feat = xr.Dataset() # 降水 ERA5 存的是米,乘 1000 换成毫米,忘了这步后面全是 1e-3 量级 pr = ds["tp"] * 1000.0 for w in (1, 3, 6, 12): feat[f"pr_cum{w}"] = pr.rolling(time=w, min_periods=w).sum() # 气温换摄氏度,再做同期距平,距平比绝对值更能反映异常 t2m = ds["t2m"] - 273.15 feat["t2m_anom"] = t2m - t2m.groupby("time.month").mean("time") # 土壤湿度只取滞后项:当月值在预测未来时是不可得的 sm = ds["swvl1"] for lag in (1, 2, 3): feat[f"sm_lag{lag}"] = sm.shift(time=lag) # 季节编码,让树模型能区分“几月”,避免用月份数字引入虚假序关系 month = ds["time"].dt.month feat["sin_m"] = np.sin(2 * np.pi * month / 12) feat["cos_m"] = np.cos(2 * np.pi * month / 12) # 转长表并落盘,parquet 比 csv 快一个数量级,体积也小 df = feat.to_dataframe().reset_index().dropna() df.to_parquet("features.parquet", index=False) print(df.shape, df["time"].min(), df["time"].max())rolling(time=w).sum()的语义是取最近 w 个月(含当月)的累积和,前 w−1 个月是 NaN,dropna()会自然切掉。降水用累积、土壤湿度用滞后、气温用距平,这三种处理对应三类不同的物理滞后响应,不是随便凑的:降水要反映“这段时间下了多少”,土壤湿度要反映“上一段时间留下的底墒”,气温距平反映的是蒸发需求的异常。
shift(time=1)是把整条序列往时间轴后方平移一格,所以 t 时刻读到的是 t−1 的值,方向不要写反。写成shift(time=-1)就是把未来值搬到现在,目标直接泄漏,训练集 R² 会飙到 0.95 以上,测试集崩到 0.3——这个坑下面还会单独说。
2.4 内存与采样:别一上来就 load 全球数据
0.1 度分辨率的全球再分析数据,几十年的月度序列,to_dataframe()一次能吃掉几十 GB 内存。我一般会按三步收:先sel裁区域,再isel做空间抽稀(比如隔两个格点取一个),最后按格点分块循环处理,每块单独写 parquet。如果研究区在流域尺度,抽稀到 0.5 度通常不会损失太多空间信息,但内存能降一个数量级。
另一个容易被忽略的是采样权重。干旱事件本身是少数类,SPEI 小于 −1 的样本可能只占 8%~15%,如果在空间上按格点等权采样,湿润区和干旱区的样本量严重不平衡,模型会整体偏向“正常”这一档。处理方式是在训练时给样本加权,权重取标签偏离 0 的绝对值,或者按 SPEI 分档做分层抽样。这个设置在后面的模型章节会用到。
3. 建模与验证:Notebook 里的四类模型与时间切分
数据表建好之后,Notebook 的部分反而清爽很多。重点有两个:一是 Notebook 的文件组织要能让人在三个月后回来还看得懂,二是切分方式必须尊重时间顺序,否则所有指标都是假的。
3.1 Notebook 拆成几个才不至于失控
单个 Notebook 写到两百个单元就开始失控了:变量名重复、执行顺序混乱、中间结果全靠内存。我习惯按数据流向拆成三段,每段只负责一件事,中间结果落盘:
| 编号 | 职责 | 输入 | 输出 |
|---|---|---|---|
| 01_download | 提交数据请求、校验坐标和单位 | 平台接口 | 原始 NetCDF |
| 02_features | 单位换算、滚动、滞后、拼表 | 原始 NetCDF | features.parquet |
| 03_train | 切分、训练、评估、导出模型 | features.parquet | 模型文件 + 指标表 |
环境固定这件事不要省。Notebook 里最典型的翻车是“昨天还能跑,今天 xarray 升级了,groupby行为变了”。用一份显式的依赖文件锁住版本:
# 建环境,python 版本按依赖兼容性选,不要用最新 conda create -n drought python=3.10 -y conda activate drought pip install xarray pandas scikit-learn lightgbm pyarrow netcdf4 cdsapi # 锁死当前版本,写进项目根目录,三个月后还能重建 pip freeze > requirements.lock.txt除此之外,在每个 Notebook 的第一个单元固定随机种子,并把数据快照的哈希值打印出来。同一份 parquet 训出来的结果应该完全可复现,如果两次跑出来差很多,先怀疑数据表被覆盖了。
3.2 时间切分:随机 KFold 会把 R² 抬到 0.95
这是整个项目最容易出错、也最容易被忽略的一步。气象水文序列的月度自相关通常在 0.6~0.9,相邻月份的特征几乎一样。如果直接train_test_split(random_state=42),训练集里必然混进测试年份前后相邻的月份,模型等于提前看到了答案。
正确的做法是按年份硬切:
import pandas as pd df = pd.read_parquet("features.parquet") df["year"] = pd.to_datetime(df["time"]).dt.year # 训练 / 验证 / 测试按时间顺序三段切,绝不交叉 train = df[df["year"] <= 2014] val = df[(df["year"] >= 2015) & (df["year"] <= 2018)] test = df[df["year"] >= 2019] print(len(train), len(val), len(test))验证集用来调超参和早停,测试集只在最后跑一次。测试集起点放在 2019 还有一个好处:那一年之后的数据分布跟训练期有明显差异,正好能检验模型在“没见过的气候条件”下会不会塌。如果测试集指标比验证集掉超过 30%,通常不是模型不行,是特征里掺了训练期特有的信息,比如用了全时段均值做的标准化。
标准化本身也要小心。StandardScaler必须只在训练集上fit,然后transform验证集和测试集。图省事写成fit_transform(全量)是很常见的隐性泄漏,尤其在特征维度多的时候,它带来的精度提升会让你误以为模型很好。
3.3 模型对比:线性、随机森林、梯度提升怎么取舍
Notebook 里一般会并排跑三类模型,各自解决不同问题:Ridge 作为基线,告诉你这批特征里线性可解释的部分有多少;随机森林检验非线性交互,且对异常值不敏感;LightGBM 在样本量中等、特征几十维的场景下通常效果最好,训练也快。
from sklearn.linear_model import Ridge from sklearn.ensemble import RandomForestRegressor from lightgbm import LGBMRegressor, early_stopping from sklearn.preprocessing import StandardScaler FEATURES = [c for c in df.columns if c not in ("time", "year", "lat", "lon", "spei3")] TARGET = "spei3" scaler = StandardScaler().fit(train[FEATURES]) # 只在训练集上 fit models = { "ridge": Ridge(alpha=1.0), # 叶节点样本数给大一点,气候数据噪声重,叶子太小必然过拟合 "rf": RandomForestRegressor(n_estimators=400, min_samples_leaf=30, max_features=0.5, n_jobs=-1, random_state=42), # 学习率小 + 树多,配合早停,比一次性调大学习率稳 "lgbm": LGBMRegressor(n_estimators=3000, learning_rate=0.03, num_leaves=31, min_child_samples=60, subsample=0.8, subsample_freq=1, colsample_bytree=0.8, random_state=42), } for name, model in models.items(): if name == "lgbm": model.fit(scaler.transform(train[FEATURES]), train[TARGET], eval_set=[(scaler.transform(val[FEATURES]), val[TARGET])], eval_metric="l2", callbacks=[early_stopping(stopping_rounds=100, verbose=False)]) else: model.fit(scaler.transform(train[FEATURES]), train[TARGET])参数上有几个经验值值得记一下。min_samples_leaf和min_child_samples控制的是同一个东西——叶子节点最小样本数。气候数据里单格的月度噪声很大,叶子太小模型会去记住某一年某一场雨的细节,验证集上立刻现原形,30~80 是比较稳的区间。learning_rate配n_estimators是权衡关系,0.03 配 3000 轮再加早停,比 0.1 配 300 轮更不容易过拟合。subsample和colsample_bytree压到 0.8 是给随机性留口子,特征之间共线性强的时候尤其有用——降水累积的 1、3、6、12 月四个窗口相关性很高,全量特征会让树模型反复在同一个信息源上分裂。
LSTM 之类的序列模型在这个场景里不是不能上,但边界很明确:样本量要够(单格点几百条月度记录根本不够,得把空间维度摊平或者做迁移),训练成本高,收益往往不如把滞后特征手工做厚。如果只是想把项目跑通,前三个模型足够。
3.4 评估指标:RMSE 好看没用,得看干旱事件命中率
回归任务默认看 RMSE 和 R²,但在干旱预警场景里,这两个指标会骗人。模型把整体预测值往均值方向压一点,RMSE 反而更低,可它恰恰把极端干旱的幅度抹平了,而极端事件才是业务真正关心的。
所以我一般会补一组事件型指标,按 SPEI 小于 −1.0 作为一次干旱事件的判定阈值:
import numpy as np def event_scores(y_true, y_pred, thr=-1.0): obs = y_true < thr pred = y_pred < thr hits = np.sum(obs & pred) misses = np.sum(obs & ~pred) false_alarms = np.sum(~obs & pred) pod = hits / max(hits + misses, 1) # 命中率:真干旱里报出来多少 far = false_alarms / max(hits + false_alarms, 1) # 空报率:报出来的有多少是假的 csi = hits / max(hits + misses + false_alarms, 1) # 临界成功指数,综合指标 return {"POD": round(pod, 3), "FAR": round(far, 3), "CSI": round(csi, 3)} # 用验证集调阈值,测试集只做最终确认 for thr in (-0.8, -1.0, -1.3): print(thr, event_scores(val[TARGET].values, pred_val, thr))阈值不是固定的。SPEI 的定义在空间上是标准化的,但不同气候区的实际分布仍有差异,干旱区常年偏干,固定 −1.0 会导致大量空报。做法是在验证集上扫一遍阈值,挑 CSI 最高的那个,把数值记进模型配置里,测试集严禁再调。
三档指标的含义差别要清楚:POD 高说明漏报少,FAR 低说明误报少,CSI 是两者的折中。实际业务里漏报的代价远大于误报,通常会接受 FAR 偏高一点,把 POD 拉到 0.7 以上。
4. 避坑排查:五个把 R² 从 0.9 打回 0.6 的真实故障
这一章是按踩坑顺序排的,几乎每一个都曾经让我在 Notebook 前面坐了两小时。
4.1 降水特征全是 0.001 量级,模型学了个寂寞
现象:训练能跑通,但降水类特征的分布集中在 1e-3 附近,模型重要性排序里降水全部垫底。
原因:ERA5 的tp单位是米,没有乘 1000 转成毫米。同时total_precipitation在某些产品里是累积量,某些是平均通量,单位属性attrs["units"]不打印出来根本看不出来。
解决:在数据加载后立刻做一次单位断言,把量级检查写进 Notebook:
pr_mm = ds["tp"] * 1000.0 assert 0 <= float(pr_mm.max()) < 2000, "降水单位可能没换算,先查 attrs"这条断言拦住过好几次问题,尤其是换数据产品的时候。
4.2 用了 shift(-1),目标泄漏进特征
现象:随机森林训练集 R² 0.97,测试集 0.28,特征重要性第一名是一个叫sm_lag1的变量。
原因:为了做“预测下一月”,把土壤湿度写成了shift(time=-1),等于把下一个月的观测放进了当月的特征里。任何树模型都会立刻抓住这个变量。
解决:所有特征的时间语义统一成“t 时刻只能看到 t 及以前的数据”,在函数入口加一条注释说明方向,并且用assert feat_time <= target_time之类的方式做一次抽样校验。滞后特征的方向错误是这类项目里最隐蔽也最致命的问题。
4.3 fillna(0) 把缺测当成“没下雨”
现象:某些格点的样本量异常多,且这些格点的预测残差系统性偏大。
原因:卫星产品和再分析产品在早期年份、水体、高纬地区有大量缺测,to_dataframe()之后直接fillna(0),等于告诉模型“这里降水为零、土壤湿度为零”,凭空造出一批极端干旱样本。
解决:先统计每个格点的有效观测比例,低于 70% 的直接剔除;剩余的少量缺测用时间维的前向填充(ffill)并加一个is_missing标记位,让模型自己学这个标记的含义。绝对不要用 0 填。
4.4 随机 KFold 把精度虚抬了三十个点
现象:换成KFold(shuffle=True)之后,交叉验证 R² 从 0.62 跳到 0.94,但上测试集立刻打回原形。
原因:月度序列自相关强,随机打乱后同一年的相邻月份被分到训练和验证两边,模型在验证集上看到的基本是“见过的样本的邻居”。
解决:改用TimeSeriesSplit或者直接按年份切。判别方法很简单,画一张预测值对观测值的散点,如果验证集散点紧密贴着 1:1 线而测试集散成一团,基本可以确定是切分泄漏。
4.5 早停用了测试集,最后连测试集都污染了
现象:反复调参之后,测试集指标一直在涨,但换一年数据重跑就完全对不上。
原因:用eval_set早停时把测试集传了进去,等于用测试集决定训练轮数,调了十几次之后测试集已经变成验证集了。
解决:早停只用验证集,测试集在整轮实验里只跑一次,跑完把结果写到单独的results/test_metrics.json里并记录时间戳。如果忍不住反复看测试集,说明验证集切得太小,应该扩大验证区间而不是偷看测试集。
5. 结果落地:把 SPEI 预测值还原成干旱等级与空间图
模型输出的是一串连续浮点数,业务方要的是“哪几个区域、什么等级、持续多久”。这一步的转换看着简单,实际决定了整套结果好不好用。
5.1 从回归值到等级:阈值要和标签定义一致
SPEI 有一套通行的分级标准,转换时直接按区间切,不要在中间再做一次自定义归一化,否则图和标签对不上:
| SPEI 区间 | 等级 | 常见处理 |
|---|---|---|
| ≤ −2.0 | 极端干旱 | 单独出图,样本极少,指标单列 |
| −2.0 ~ −1.5 | 重度干旱 | 预警重点区间 |
| −1.5 ~ −1.0 | 中度干旱 | 与事件判定阈值对齐 |
| −1.0 ~ −0.5 | 轻度干旱 | 边界易抖动,建议做三月平滑 |
| −0.5 ~ 0.5 | 正常 | 占样本大多数 |
| > 0.5 | 偏湿 | 一般不作为分析重点 |
import numpy as np import pandas as pd BINS = [-np.inf, -2.0, -1.5, -1.0, -0.5, 0.5, np.inf] LABELS = ["极端干旱", "重度干旱", "中度干旱", "轻度干旱", "正常", "偏湿"] def to_category(spei_values): # right=False 让区间左闭右开,避免阈值点上重复计数 return pd.cut(spei_values, bins=BINS, labels=LABELS, right=False) test = test.assign(level=to_category(pred_test)) print(test.groupby("level", observed=True).size())right=False这个参数值得留意,默认是左开右闭,阈值正好等于 −1.0 的样本会被分到轻度那一档,和事件判定用的< -1.0对不上,统计出来的干旱月数会少一截。
等级图的抖动也常见。逐格点独立预测,相邻月份的等级会在“轻度”和“正常”之间来回跳,看上去很不稳定。做法是对预测序列做三个月滑动平均再分级,物理上也说得通——干旱本身就是一个缓变过程,一个月的等级跳变多半是模型噪声。
5.2 空间出图与人工校核
把长表还原成 (time, lat, lon) 的 DataArray 才能画图。转换时注意格点顺序,转回去的时候纬度轴要和原始数据一致,否则图会上下颠倒:
import xarray as xr # 从长表回到网格,索引顺序要和原始坐标严格一致 grid = test.set_index(["time", "lat", "lon"])["pred"].to_xarray() # 挑一个典型月份出图,色标反向:越干越红 one_month = grid.sel(time="2019-08-01") one_month.plot(cmap="RdYlBu", vmin=-2.5, vmax=2.5)色标一定要反向,RdYlBu默认是红高蓝低,用在干旱图上会把最干的区域画成蓝色,汇报时非常尴尬。另一种做法是直接把等级映射成离散色表,可读性更好,也不容易误读。
出图之后必须做人工校核,否则你不知道模型是在预测还是在编。我一般会走三条路:一是拿历史上公认的干旱年份做回测,看模型能不能复现出空间格局;二是和同期植被指数对比,干旱区应该出现明显的植被胁迫信号,如果模型报旱但植被指数正常,八成是特征有问题;三是看时间连续性,真实的干旱事件通常持续三到六个月,如果预测出来的干旱都是单月孤岛,说明模型在拟合噪声。
三条都过不了的模型,不要急着上业务,先回去看特征。
5.3 导出与元数据:别让结果变成黑匣子
结果导出成 NetCDF 时,把模型信息写进全局属性。这一步很多人省掉,半年后别人拿到文件完全不知道是用什么训的:
grid.attrs.update({ "model": "lgbm", "train_period": "1990-2014", "val_period": "2015-2018", "target": "spei3", "event_threshold": -1.0, "features": ",".join(FEATURES), }) grid.to_netcdf("spei3_prediction.nc")属性里最关键的是event_threshold和features。前者决定了这份预测按什么标准判旱,后者决定了换数据源时能不能复用。特征名一变,模型文件就是个摆设。
6. 进阶:用分位数回归做概率预警,比点预测更抗误报
点预测给的是“SPEI 大概是多少”,但预警真正需要的是“这个月发生中度以上干旱的概率有多大”。两者在业务上的差别很大:点预测 −1.1 和 −0.9 看起来差不多,但前者跨过了阈值,后者没有,而单点回归在阈值附近极不稳定。
改用分位数回归可以把这个不确定性直接建模出来。LightGBM 支持分位数目标,训练三个模型分别拟合 0.1、0.5、0.9 分位,0.1 分位低于 −1.0 就可以理解为“有较高概率发生干旱”:
from lightgbm import LGBMRegressor quantile_models = {} for q in (0.1, 0.5, 0.9): m = LGBMRegressor(objective="quantile", alpha=q, # alpha 就是目标分位数 n_estimators=2000, learning_rate=0.03, num_leaves=31, min_child_samples=60, random_state=42) m.fit(scaler.transform(train[FEATURES]), train[TARGET]) quantile_models[q] = m # 0.1 分位低于阈值,就认为存在干旱风险,比点预测的单点判定稳得多 p10 = quantile_models[0.1].predict(scaler.transform(test[FEATURES])) risk = p10 < -1.0 print("干旱风险月占比:", round(risk.mean(), 3))这套做法的好处是能给出区间宽度。0.1 和 0.9 分位拉得很开,说明模型对这个样本没有把握,业务上就不该给强预警;区间收得很窄且整体偏低,才是真正值得推送的信号。实测下来,用区间下界做判定,空报率通常比点预测低三到五成,代价是命中率会掉一点——这个取舍在预警场景里通常是划算的。
另一个值得加的技巧是滚动多步预测。上面的模型预测的是未来一个月,要做三个月窗口,可以递归地把预测值当作下一轮的输入喂回去,但误差会累积。更稳的做法是给每个预测步长单独训一个模型(direct multi-step),代价是三倍训练成本,换来的是误差不叠加。窗口越长,这个选择越重要,超过三个月之后递归法的结果基本没法看。
还有一个常被忽略的点是区域自适应阈值。SPEI 虽然做了标准化,但在极干旱区,−1.0 这个阈值对应的实际水分亏缺远大于湿润区,固定阈值会导致干旱区常年空报。我一般会按气候区分别校准,每个区用自己验证集上的最优阈值,把结果写进一张区域配置表,预测时按格点所属区域查表。
最后说个习惯。这个项目我前后跑过几轮,最疼的一次不是模型调不出来,而是某次为了赶进度,跳过了时间切分的检查,直接用随机划分跑出了 0.94 的 R²,图出得也漂亮,交上去之后被问了一句“2019 年那场旱你们提前多久报出来的”,才发现根本对不上。从那以后我每次开新的时序建模 Notebook,第一步都强制写一段切分校验代码,把训练、验证、测试的时间范围打印出来,确认三段没有重叠才开始训。这个动作花不了一分钟,但能省掉一整轮返工。希望帮到你。
本文还有配套的精品资源,点击获取