news 2026/10/16 16:36:16

GEE与Python结合:标准化降水指数SPI计算全流程解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
GEE与Python结合:标准化降水指数SPI计算全流程解析

做干旱监测的人对标准化降水指数(SPI)应该都不陌生。这个指数用一段降水序列拟合概率分布,再映射到标准正态空间,最后得到一个正负浮点值,正值偏湿、负值偏旱,用来判断某个区域某个时段的水分异常非常直观。但真正动手算过 SPI 的人都知道,麻烦不在于公式,而在于“降水数据从哪来、怎么整理成可计算的序列”。

我这次要聊的工作流是:通过 GEE 的 Python 接口取降水栅格数据,在本地用 climate_indices 包批量计算 SPI,最后做可视化。很多教程把这两步混在一起讲,实际上 GEE 只负责数据供给,SPI 的计算全在本地 Python 环境里完成。这样拆开有一个好处:换数据源、换区域、换时间尺度都只改一小段代码,不用动核心计算逻辑。

下面按我实际操作时的顺序展开,从数据选型到导出、从 SPI 参数设置到画图,最后复盘几个踩过的坑。这篇文章适合正在做干旱监测、水文气象分析,或者刚接触遥感降水产品的研究生和从业者,照着自己的需求替换参数就能跑通。

1. 降水数据源的选择:GEE 里三款主流产品的取舍

1.1 为什么把取数这一步放在 GEE

以前做站点级别的 SPI 分析,最头疼的是降水数据整理:要么从气象站要逐月观测值,要么下载再分析资料再转格式,数据质量参差不齐,时间长度也不统一。用 GEE 取数之后,这些问题被大幅简化——所有降水栅格都在同一个平台里,时间范围、空间范围可以靠代码直接控制,导出结果又是一份规整的表格或栅格,不用手工拼接。

尤其做区域尺度(比如一个流域、一个省、一块几万平方公里的样区)的 SPI 时,GEE 的优势更明显。你不需要在本地维护一堆历史栅格文件,只需要在代码里写清楚“从哪一天到哪一天、哪一块区域、哪个数据集”,剩下的交给人家的服务器去算。算完把结果拉到本地,进入 Python 分析环节。

需要说明的是,GEE 取到的降水是栅格产品的估值,不是站点实测。这意味着它适合做空间上的连续分析,但如果你的研究区有密集的站点数据,还是以站点资料为准,栅格产品只做参考或填补空白。

1.2 CHIRPS、ERA5-Land、PERSIANN-CDR 怎么选

我在 GEE 里常用的日尺度降水产品主要有三个,各有各的脾气。

产品GEE 集合 ID空间分辨率起始年份原始单位适合场景
CHIRPSUCSB-CHG/CHIRPS/DAILY约 0.05 度(5.5 公里)1981 年mm/day站点稀疏区、干旱监测、季风区
ERA5-LandECMWF/ERA5_LAND/DAILY_AGGR约 0.1 度(11 公里)1950 年m(日累计总量)长时序分析、全球覆盖、再分析研究
PERSIANN-CDRPERSIANN-CDR约 0.25 度(25 公里)1983 年mm/day长时序粗尺度趋势分析

CHIRPS 是“站点观测 + 卫星反演 + 地形修正”融合出来的产品,时空连续性在干旱监测里表现稳定,也是很多国际干旱监测系统的默认输入之一。它的分辨率是三款里最高的,做中小区域(比如几万平方公里的流域)很适合。代价是站点较少的地区,比如某些内陆高山区,CHIRPS 可能会被低估或高估,最好和当地站做一期对比再定。

ERA5-Land 是再分析产品,它的优势是时间跨度长,从 1950 年就有逐日数据,做 40 年以上的长时序 SPI 非常合适。但它的原始降水单位不是毫米,而是米,导出时如果忘了换算,后面算出来的 SPI 数值会整体漂移,这一点我放在最后的踩坑清单里专门说。

PERSIANN-CDR 分辨率最低,一般我不做精细区域分析用,只有在需要 1983 年至今的全球尺度长序评估时才会考虑它。实际上它和 CHIRPS 在月尺度上的趋势很接近,但空间细节差不少。

1.3 我的选型建议

如果你只需要一套数据,优先 CHIRPS。原因是 SPI 本身是一种“标准化相对指数”,它看重的是降水序列的统计分布,而不是绝对降水量,CHIRPS 的几十年连续序列恰好能满足这个需求。

如果你的研究区在高纬度或者需要 1981 年之前的历史时段,ERA5-Land 是更稳的选择,但务必先把单位从米换算成毫米。如果论文或报告要求多源交叉验证,那就把 CHIRPS 和 ERA5-Land 都跑一遍,看看 SPI 的时空格局是否一致;如果两者在关键旱季上的结论差异很大,那说明该区域降水产品不确定性较高,需要用站网数据进一步仲裁。

2. 从影像到一维时间序列:GEE 导出这一步别卡壳

2.1 定义研究区和影像集

我建议在本地用 Python 初始化 GEE 环境,直接在 Notebook 里操作。首先定义研究区,我这里用一个虚构的矩形区域做演示,坐标大致是东经 100 到 104 度、北纬 30 到 34 度,大约 4 度见方:

import ee # 如果是从零开始,先初始化 # ee.Authenticate() ee.Initialize() roi = ee.Geometry.Polygon([ [100.0, 30.0], [104.0, 30.0], [104.0, 34.0], [100.0, 34.0], ]) chirps = ee.ImageCollection("UCSB-CHG/CHIRPS/DAILY").select("precip")

这里有个细节:我在加载影像集时就用了 select 只保留降水波段。单看这一步无所谓,但如果你同时加载多个波段,后面做 reduceRegion 的时候会多出一堆用不上的数据,白白增加运行时间和导出体积。尽早瘦身是 GEE 取数的一个好习惯。

2.2 按自然月聚合的代码骨架

SPI 一般基于月尺度降水。CHIRPS 的原始数据是逐日 mm/day,所以一个自然月的月总量,就是把该月各天的降水值累加。这里必须按日历月来聚合,不能简单地用滚动 30 天,否则 SP 序列的相位会偏。

我常用的做法是生成一个“年 × 月”的编号序列,比如 198101、198102……然后在 GEE 端逐月做 reduceRegion。一个可用于中小区域面均值的示例代码如下:

def monthly_mean(ym): year = ym.divide(100).floor() month = ym.mod(100) start = ee.Date.fromYMD(year, month, 1) end = start.advance(1, "month") img = chirps.filterDate(start, end).sum() mean = img.reduceRegion( reducer=ee.Reducer.mean(), geometry=roi, scale=5500, ).get("precip") return ee.Feature(None, {"date": start, "precip": mean}) ym_list = ee.List.sequence(198101, 202312, 1) fc = ee.FeatureCollection(ym_list.map(monthly_mean))

注意这里的 scale 参数。CHIRPS 分辨率约为 0.05 度,对应赤道附近约 5500 米。GEE 的 reduceRegion 允许你指定一个比原始分辨率略大的尺度来做区域平均,但不要给一个明显不合理的值,比如 100 米,那会触发大量不必要的计算。5500 米对 CHIRPS 来说比较合适。

2.3 导出到本地后的数据形态

上面的 fc 是一份 FeatureCollection,每个要素包含 date 和 precip 两个字段。导出到云端硬盘再下载,用标准的 export 函数就行:

task = ee.batch.Export.table.toDrive( collection=fc, description="chirps_monthly_roi", folder="GEE_EXPORT", fileNamePrefix="chirps_monthly", fileFormat="CSV", ) task.start()

导出的 CSV 大致长这样:

system:index,date,precip 0,1981-01-01T00:00:00,7.83 1,1981-02-01T00:00:00,4.12 ...

在本地用 pandas 读进来时,最好先把时间字段统一成“年-月”的格式,避免后续处理时区的问题:

import pandas as pd df = pd.read_csv("chirps_monthly.csv") df["month"] = pd.to_datetime(df["date"], utc=True).dt.strftime("%Y-%m") df = df.set_index("month") df.index = pd.PeriodIndex(df.index, freq="M") monthly = df["precip"].astype(float)

这里把日期统一成 PeriodIndex 之后,后面计算 SPI 只需要取.values和一个起始年份,逻辑会非常干净。

2.4 单点直接取 vs 面均值导出

如果你研究区是一个点,或者只有几个站点位置,不必导出 CSV,直接用 sample 就能把像元值拉回本地。比如:

point = ee.Geometry.Point([102.0, 32.0]) def monthly_mean_at_point(ym): year = ym.divide(100).floor() month = ym.mod(100) start = ee.Date.fromYMD(year, month, 1) end = start.advance(1, "month") img = chirps.filterDate(start, end).sum() value = img.reduceRegion( reducer=ee.Reducer.mean(), geometry=point, scale=5500, ).get("precip") return ee.Feature(None, {"date": start, "precip": value})

不过说实话,除非你只需要一两个点,否则还是做成面均值的 FeatureCollection 再一次性导出更省事。单个点一条条拉回本地的写法在 GEE 里会有大量往返请求,点一多就容易踩到计算配额限制。

3. Climate Indices 包的 SPI 计算:参数、原理与版本差异

3.1 安装与新旧版 API 的坑

这一步的主角是 climate_indices 包,pip 直接安装:

pip install climate-indices

这个包的版本升级有点让人头疼。老版本(0.2.x 时期)的写法是直接用 indices 模块里的函数:

from climate_indices import indices spi_values = indices.spi( precip, timescale=3, distribution=indices.Distribution.gamma, data_start_year=1981, calibration_year_start=1981, calibration_year_end=2010, )

新版本(0.3 以上)改成了类接口:

from climate_indices import ClimateIndices ci = ClimateIndices() spi_values = ci.spi( precip=precip, frequency="monthly", timescale=3, distribution="gamma", data_start_year=1981, calibration_year_start=1981, calibration_year_end=2010, )

如果你在网上搜到的是旧写法,而本地装的是新版本,import 或者调用时会直接报错。我的建议是装好后先print(climate_indices.__version__),再决定用哪种 API。下文统一用新版类接口。

3.2 核心参数逐个说明

把月度降水序列读成 numpy 数组之后,调用 SPI 的主代码是这样:

import numpy as np from climate_indices import ClimateIndices precip = monthly.values.astype(float) start_year = int(monthly.index[0].year) end_year = int(monthly.index[-1].year) ci = ClimateIndices() spi3 = ci.spi( precip=precip, frequency="monthly", timescale=3, distribution="gamma", data_start_year=start_year, calibration_year_start=start_year, calibration_year_end=2010, )

逐项拆开看:

  • precip必须是一维数组,按时间升序排列,长度要足够覆盖校准期。如果序列里有缺失值,最好先补全再传入,否则结果里会出现意想不到的空洞。
  • frequency填"monthly"或"daily"。注意这个参数描述的是输入序列的采样频率,不是你要算的时间尺度。你输入的是月序列,就填月,输入日序列就填日。
  • timescale是累积窗口。timescale=3得到 SPI-3,表示最近 3 个月的累积降水在历史同期分布中的位置。1、3、6、12 是最常用的四档,分别对应短期土壤湿度异常、季节性水分异常和长期水文干旱。
  • distribution填"gamma"或"pearson3"。SPI 的经典定义用的是伽马分布,这也是多数国际用户的默认选项。某些国家干旱监测系统偏好 Pearson III 型分布,两种在绝大多数情形下结果差异很小。
  • data_start_year是输入序列的起始年份,包内部会用它来定位每个值的时间位置,所以必须填准确,尤其是当你的序列不是从 1 月开始的时候。
  • calibration_year_start和calibration_year_end是分布拟合的校准期。常规做法是取气候标准期,比如 30 年。校准期越长,拟合出的分布参数越稳定,后面的 SPI 值也就越可信。

返回的数组长度和输入一样,但前几个值会是 NaN。具体来说,SPI-3 的前 3 个值算不出来,因为累积窗口还没填满。这不是 bug,后面画图的时候可以用同样的 x 轴索引直接画,让 NaN 自然断掉即可。

3.3 SPI 到底是怎么算出来的

为什么一个看起来只是“降水标准化”的指数,能成为干旱监测的标配?因为它的计算过程把气候背景差异抹掉了:某个区域年均降水量 300 毫米,另一个区域年均 1200 毫米,直接用绝对降水量没法比较,但转换成 SPI 之后,两地的干湿程度就处于同一尺度上。

计算过程大致分四步:

  1. 先把最近timescale个月的降水累加。比如 SPI-3 在 5 月,就用 3 月、4 月、5 月三个月的降水之和。
  2. 对累计降水序列拟合伽马分布,得到形状参数和尺度参数。
  3. 把每个累计值转成累积概率。这里有个关键细节:当月降水存在大量零值时,伽马分布在零处没有定义。通常的做法是构造一个混合累积概率,把“零降水出现的经验频率”单独拿出来,非零部分再用伽马分布拟合。
  4. 最后把累积概率映射到标准正态分布的分位数上。比如累计概率是 0.1,对应的标准正态分位数约是 -1.28,这个值就是 SPI。

所以 SPI 正值表示降水偏多,负值表示偏少,数值离 0 越远说明异常程度越高。常用分级如下:

SPI 区间分类
2.0 及以上极度湿润
1.5 到 1.99非常湿润
1.0 到 1.49中等湿润
-0.99 到 0.99接近正常
-1.0 到 -1.49中等干旱
-1.5 到 -1.99严重干旱
-2.0 及以下极端干旱

这也是为什么很多人喜欢在图上画 -1、-1.5、-2 三条虚线:它们正好对应干旱从启动到加强的几道门槛。

4. 可视化落地的两条路线:时间曲线与空间分布图

4.1 时间序列图:参考线、填充和双轴

算完 SPI 之后,最常用的可视化就是时间序列图。我一般用 matplotlib 画,三步走:画参考线、填充正负区域、叠加降水量做双轴。

import matplotlib.pyplot as plt fig, ax = plt.subplots(figsize=(12, 5)) x = monthly.index.to_timestamp() # SPI 曲线 ax.plot(x, spi3, color="#2166ac", lw=1.2) ax.axhline(0, color="#333333", lw=0.8) for level, color in [(-1, "#8c8c8c"), (-1.5, "#f39c12"), (-2, "#c0392b")]: ax.axhline(level, ls="--", lw=0.8, color=color) ax.fill_between( x, spi3, 0, where=(spi3 >= 0), color="#2166ac", alpha=0.25 ) ax.fill_between( x, spi3, 0, where=(spi3 < 0), color="#b2182b", alpha=0.25 ) ax.set_ylabel("SPI-3")

填充颜色的意义在于,一眼扫过去就能看到哪些时段持续偏旱、哪些时段偏湿,比单看折线直观很多。如果你想同时呈现降水量的变化,可以在同一个图上加一个次级坐标轴,把月降水画成柱状:

ax2 = ax.twinx() ax2.bar(x, monthly.values, color="#d9d9d9", alpha=0.6, label="Precip") ax2.set_ylabel("Precipitation (mm)")

这时 SPI 曲线叠加在灰色柱子上,降水异常和 SPI 波动的对应关系非常清楚。类似的图在干旱监测报告里很常见,做法不复杂,但信息密度很高。

4.2 空间分布图:逐像素算 SPI 的正确姿势

时间序列图只适合呈现一个点或一个区域均值。如果想看某年某月的 SPI 在空间上怎么分布,就得扩展到每个像素。这也是“GEE python 算 SPI”最容易让人误入歧途的地方:GEE 服务器端并不适合直接做伽马分布拟合和分位数映射,因为你没法方便地在服务器端代码里调用 climate_indices 包。我试过几种方案,最终的落地做法是:

  1. 在 GEE 端把整个区域按月聚合,导出月降水栅格序列,格式可以选 GeoTIFF,也可以整理成 NetCDF。
  2. 本地用 xarray 打开,得到一个三维数组(time, latitude, longitude)。
  3. 自定义一个包装函数,调用 climate_indices 里的 SPI 函数。
  4. 用xr.apply_ufunc沿着时间维逐像素点应用这个函数。

核心思路如下:

import xarray as xr ds = xr.open_dataset("monthly_precip.nc") precip_da = ds["precip"] def calc_spi(ts): arr = ci.spi( precip=ts, frequency="monthly", timescale=6, distribution="gamma", data_start_year=1981, calibration_year_start=1981, calibration_year_end=2010, ) return arr spi_da = xr.apply_ufunc( calc_spi, precip_da, input_core_dims=[["time"]], output_core_dims=[["time"]], vectorize=True, )

vectorize=True会把这个函数逐像素跑一遍,代价是速度比较慢。如果区域很大,先在小范围测试,再放开到全区域。画图的时候可以用 cartopy 叠加海岸线、河流等地理要素,这是目前最稳妥的组合。

这里要提醒一句:导出的月降水栅格序列在时间维上必须是完整的,不能缺月份。缺一个月份,累积窗口就会错位,SPI 结果会在该时段附近出现畸形跳变。检查时间维的长度是最基本的一步。

5. 实操复盘:六个容易翻车的细节

5.1 数据预处理相关的三个坑

第一个坑是单位。CHIRPS 的原始值是 mm/day,月度累加之后得到 mm/month,这个量级是对的。但 ERA5-Land 的 total_precipitation 单位是米,换算成毫米需要乘 1000。如果忘记这一步,SPI 的符号可能不会变,因为标准化处理对整体缩放不敏感,但校准期分布参数会受影响,最终 SPI 数值和正确结果有明显偏差。这种错误很隐蔽,因为它不会直接报错。

第二个坑是时间聚合边界。CHIRPS 的逐日影像按 UTC 日期归档,聚合月份时一定要用日历月的边界,也就是从当月 1 日开始到下月 1 日之前。如果你在中途用了滚动 30 天窗口,SPI 序列会和真实月尺度错位,尤其月末几天降水多的时候,差异会很明显。

第三个坑是缺失月份。某些极端干旱区域或高云覆盖区,月聚合可能会出现全 NaN 的像元。最好在导出之前先检查每个月份影像的数量,比如chirps.filterDate(start, end).size(),如果数量不足,需要决定是补 0 还是插值。直接把 NaN 丢给 climate_indices 包,大概率会在拟合阶段翻车。

5.2 包使用相关的两个坑

第四个坑是新旧版本 API 混用。很多人会在 Stack Overflow 上找到 0.2.x 时期的代码,粘贴后发现indices.Distribution不存在。其实解法很简单,升级到新版本后统一用ClimateIndices类接口;如果项目环境必须锁定老版本,那就要用老接口。最省心的办法是在项目 requirements 里固定版本号。

第五个坑是校准期参数选择。校准期太短,比如只有 5 年,SPI 的标准差会被样本量拉偏,导致后期所有值都偏大或偏小。至少要有 30 年连续降水序列,这是 WMO 推荐的气候标准期长度。如果序列本身只有十几年,那算出来的 SPI 只能算“相对某段时期的异常指数”,跨区域比较时要非常谨慎。

5.3 一个藏得最深的单位坑

第六个坑其实还是单位,但这次藏在 PERSIANN-CDR 这类产品里。这些产品的日降水量字段有时是 mm/day,有时是 mm/hr,取决于 GEE 集合里的波段说明。直接用之前一定要看image.getInfo()或者数据集的波段属性,确认单位换算系数。我就遇到过把 mm/hr 当天尺度直接用,结果月总量差了一个数量级的案例,当时整条 SPI 曲线看起来都在极旱区徘徊,排查了很久才发现根因在原始数据单位。

另外,SPI 计算结果的数组前几个值是 NaN,这是正常行为。有人会在这一步用dropna()把开头空值删掉,结果后面画图时 Excel 或 pandas 对齐错位,导致不同尺度的 SPI 曲线错开几个月。处理方式是保留原始索引,画图时让 NaN 自然断裂,不要轻易重排数组。

整个流程跑通之后,我的体会是:SPI 这个指数本身不算难,难的是把数据源、GEE 导出、本地包调用、可视化四段链路衔接好。每段链路单独看都很简单,但放在一起,任何一处的细节出错都会让你在下一步面对一个看起来“完全合理实则错误”的结果。建议拿到一个新数据集时,先找一个有站点降水记录的区域做一次验证,把 SPI-1 的曲线和当地实际旱情报道对照一下,确认整条链路没有系统偏差,再扩展到更大区域。这套流程稳定下来之后,换数据源、换时间尺度都只是改参数的事情。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/16 16:35:59

基于MATLAB的物理信息神经网络求解Burger方程与流动建模实践

物理信息神经网络&#xff08;PINN&#xff09;这两年几乎成了科学计算领域最热的词之一&#xff0c;尤其在偏微分方程求解、流动建模和反问题方向。以前我做流动计算&#xff0c;遇到强非线性方程&#xff0c;第一反应是加密网格、换格式、调时间推进方案&#xff1b;后来认真…

作者头像 李华
网站建设 2026/10/16 16:35:59

终端AI编程助手费用失控?free-claude-code的省钱与模型路由实践

我最近把日常开发的一大半搬进了终端&#xff0c;工作流里自然也多了 Claude Code。第一次在项目里跑通它的时候&#xff0c;我确实有点上头&#xff1a;它能自己翻代码、跑测试、改完文件还会回头给你讲思路&#xff0c;几乎是把结对编程塞进了命令行。但第二周查账单的时候我…

作者头像 李华
网站建设 2026/10/16 16:35:48

非对称纳什谈判下多微网电能共享优化策略与实现

干微电网优化的人应该都有印象&#xff1a;一个园区里&#xff0c;A栋屋顶光伏在午间发得多了&#xff0c;消纳不掉&#xff0c;逆变器被迫限功率&#xff1b;隔壁B栋是商业综合体&#xff0c;午间恰恰是负荷高峰&#xff0c;正在按尖峰电价从网上买电。两边物理上只隔几百米&a…

作者头像 李华
网站建设 2026/10/16 16:35:33

APS高级排产系统实施避坑指南:从约束建模到数据治理

简介&#xff1a;APS高级排产系统是一套面向制造业生产计划与调度场景的Java实现资料包&#xff0c;涵盖需求预测、产能规划、多级物料需求计划、约束优化等功能模块&#xff0c;适合企业信息化实施人员、ERP/MES二次开发工程师及有一定Java基础的学习者参考。压缩包内共707个文…

作者头像 李华
网站建设 2026/10/16 16:33:50

电力系统暂态稳定仿真:Matlab与Simulink故障分析实战

还在学校里啃电力系统分析的时候&#xff0c;最头疼的事情之一就是“暂态稳定”这一块。课本上把等面积法则、摇摆曲线、极限切除时间解释得清清楚楚&#xff0c;可一旦让你回答“三相短路到底比两相接地严重多少”“故障发生在线路首端和末端差别有多大”这类问题&#xff0c;…

作者头像 李华
网站建设 2026/10/16 16:30:28

STL源码剖析第六章:C++标准库算法的泛型实现与工程实践

1. 这不是一本“算法导论”&#xff0c;而是一把解剖C标准库的手术刀如果你在书架上见过那本封面泛黄、边角卷起的《STL源码剖析》&#xff0c;大概率它正安静地躺在某位C老手的案头&#xff0c;书页间夹着几根不同颜色的荧光笔标记&#xff0c;页脚密密麻麻写满批注。而第六章…

作者头像 李华