☰
GEE实现Theil-Sen与Mann-Kendall长时序趋势分析全流程指南
2026/10/3 21:41:11 网站建设 项目流程

如果你想在GEE里做长时序的植被、降水、气温或其它环境变量的趋势分析,Theil-Sen Median斜率估计和Mann-Kendall显著性检验这两件套几乎是绕不开的标配。我最早接触这个组合是在做NDVI年际变化的时候,当时用ENVI和R来回倒腾数据,光是处理栅格数量就够呛。后来完全迁到GEE上,整个流程从“数据下载、本地拼接、逐像元计算”变成了“云端一把梭”,效率和可复现性完全不是一个量级。

这篇文章我把自己在GEE里跑TS和MK趋势分析的完整流程、代码逻辑和踩坑经验整理出来。内容适合两类人:一类是你已经知道趋势分析是什么,但刚开始在GEE里动手,需要一份能直接跑的参考代码;另一类是你已经写过一些GEE代码,但对Theil-Sen和Mann-Kendall的数学细节、参数设置和结果解读还比较模糊,想彻底搞明白。两种需求我都尽量照顾到,从原理讲到实操,再到问题排查。

1. 整体设计与思路拆解

1.1 趋势分析为什么选Theil-Sen + Mann-Kendall

在环境遥感里,做长时序趋势分析最经典的组合就是“Theil-Sen斜率估计 + Mann-Kendall显著性检验”。Theil-Sen是个非参数估计方法,本质上是计算所有两两时间点之间斜率的中位数。和普通最小二乘线性回归比,它的优势非常直观:不受极端异常值影响,对非正态分布数据也适用。

Mann-Kendall则是配套的非参数趋势检验方法,它检验的是一个序列随时间单调上升或下降的显著性,不需要数据满足正态性假设,对缺失值和异常值也相当稳健。两者搭配起来,TS给出的斜率大小代表变化幅度,MK给出的是这个变化是否统计显著。这在长时序NDVI、EVI、降水、LST等指标的演变分析里是非常标准的技术路线。

在GEE出现之前,做这类分析的典型路径是:在GEE里选好数据集和时间范围,导出逐年或逐月影像,下载到本地,再用R、Python或者ENVI进行逐像元的TS和MK计算。这种方法的痛点在于,如果你研究区比较大,或者时间序列有二十年、三十年,一次导出可能就有几百张影像。每张影像又是全球或大区域尺度的栅格,本地存储和处理压力巨大,跑一次全流程轻则半小时,重则直接爆内存。

GEE解决这个问题的方式特别干净:所有数据都在云端,计算通过Map代数逐像元并行执行。我们只需要写清楚“对每个像元做一遍TS和MK”,平台会自动调度分布式计算资源,最后输出的就是趋势斜率和显著性检验结果的栅格图层。整个过程既不需要下载数据,也不需要本地配置R和Python环境。

1.2 GEE里实现TS+MK的整体流程设计

GEE里实现TS和MK,整体有四个核心环节:

  1. 构建长时序影像集合(ImageCollection),通常以年为单位合成,比如逐年NDVI最大值、逐年生长季均值。
  2. 将影像集合转为数组(array),让每个像元上承载一条完整的时间序列,这是GEE里做逐像元分析非常关键的一步。
  3. 在数组的每个像元上执行Theil-Sen斜率计算和Mann-Kendall显著性检验。
  4. 将斜率、显著性p值、以及下文的趋势分类结果输出为图层,并可视化或导出。

第一步的关键在于确定时间尺度。你做NDVI趋势,通常是逐年的最大值合成或生长季均值合成。做降水趋势,可能要按月或按季节聚合后,再序列化。时间粒度的选择直接决定结果解读的语义:年最大值合成反映的是每年最旺时段的绿度变化趋势,生长季均值则更反映整体生产力变化。这个选择要在数据准备阶段就明确,不然后面的趋势结果很难解释。

第二步是GEE实现TS和MK的精髓。GEE原生支持imageCollection.toArray()方法,这个操作会把一个ImageCollection按时间维堆叠成一个数组影像。每个像元上就是一条一维的时间序列。后面所有计算都是基于这个数组进行的,用array.reduce()配合自定义的ee.Reducer来完成。复杂的统计计算在GEE里通常不是靠reduceRegion那一套,而是靠数组操作和array.reduce。

第三步是算法实现的核心。TS和MK的理论公式看起来不复杂,但要适配GEE的数组并行计算,是需要一定技巧的。很多初学者在这一步容易抄错代码,后面我会详细讲每一行代码的逻辑。

第四步相对简单,把计算结果放在visualize()里调好色带,或者用Export.image.toDrive导出GeoTIFF到自己的云盘。

2. Theil-Sen与Mann-Kendall的核心原理拆解

2.1 Theil-Sen Median斜率的数学逻辑

Theil-Sen的公式很简单:给定一组时间序列点 $(t_1, x_1), (t_2, x_2), ..., (t_n, x_n)$,计算所有点对之间的斜率:

$slope_{ij} = \frac{x_j - x_i}{t_j - t_i}, \quad \forall j > i$

然后取这些斜率的中位数作为整体趋势斜率。换句话说,不是用最小二乘去拟合一条线,而是从所有两两连线的斜率中挑一个“最中间”的值。这样做的好处是:如果数据里有一两年是极端气候造成的异常值(比如特大干旱或洪水),它对中位数的影响远小于对最小二乘斜率的影响。而且Theil-Sen对异方差性不敏感,生态和环境数据里这种“波动幅度随时间变化”的情况很常见。

举个例子,假设你在分析某区域2000年到2020年的NDVI,其中2010年因为严重干旱NDVI骤降,最小二乘回归会因为这一年异常值把趋势线斜率拉低,甚至可能改变趋势方向;但Theil-Sen因为取的是所有两两组合斜率的中位数,这一年的异常只贡献了有限几个斜率值,整体中位数几乎不受影响。这正是环境遥感数据里我们最需要的那类稳健性。

需要强调的一点是,Theil-Sen计算的是单调趋势,不是线性趋势。它可以捕捉持续上升或持续下降的变化,但对于“先上升后下降”这种非单调变化,它给出的斜率会接近0,这时最好结合Mann-Kendall检验和分段趋势分析来综合判断。

2.2 Mann-Kendall检验的统计基础

Mann-Kendall检验不去假设数据符合某种分布,也不要求线性关系。它的基本逻辑是计算时间序列中所有点对的“符号一致性”:如果后面的值比前面大,记作正号;比前面小,记作负号。如果序列总体上有单调上升趋势,那么正号会显著多于负号;如果是下降趋势,负号显著多于正号。

具体来说,MK检验统计量S定义为:

$S = \sum_{i=1}^{n-1} \sum_{j=i+1}^{n} sign(x_j - x_i)$

当样本量较大时,S近似服从正态分布,通过标准化得到Z统计量,再计算双尾p值。这里的核心是p值是否小于某个显著性水平(通常选0.05)。如果p < 0.05,说明趋势在统计上是显著的;如果p值较大,说明观察到的变化有可能只是随机波动,不代表真实趋势。

MK检验的输出还有一个常见用途,就是和Theil-Sen斜率组合得到趋势分类:显著上升、显著下降、不显著上升、不显著下降、无明显趋势。这种分类结果通常用于土地退化、植被恢复、荒漠化监测等应用里,是政策制定和生态评估的重要依据。

举个例子,你计算出来某区域的NDVI斜率是0.002/年,看起来在变绿。但如果MK检验的p值是0.3,那你就要小心了,因为这意味着这个上升趋势在统计上并不显著,很可能只是年际波动的随机结果。只看斜率不看显著性,是所有趋势分析里最容易犯的错误。

2.3 为什么GEE里这两者适合组合实现

Theil-Sen和Mann-Kendall都是基于点对的计算,这意味着在GEE的数组框架里,它们可以被高效地向量化实现。你不需要写循环,只需要将时间序列数组扩展成一个二维矩阵,利用矩阵运算一次性算出所有点对符号和斜率,然后用array.reduce()来汇总统计量。这正是GEE这种云原生计算平台最喜欢的模式。

如果用传统的逐像元循环方法,代码复杂度高,而且在大范围研究区上跑起来非常慢。而GEE里通过数组升维和矩阵运算,可以把原本需要无数个循环的计算转化为少数几个map和reduce操作,极大提升效率。

从实际使用的角度看,GEE内置的ee.Reducer.sensSlope()已经实现了Theil-Sen斜率估计和Mann-Kendall检验的组合,不过它返回的结果有限,只有斜率和截距,并没有直接给出MK的p值。如果需要p值和Z统计量,就需要自己实现MK检验部分。我自己常用的做法是:核心的p值计算用ee.Reducer的get方式从自定义函数里提取,斜率可以用内置的sensSlope得到也可以用自定义函数验证一遍。

这里我提醒一下,不同版本的GEE内置Reducer接口可能会有细微变化,建议官方文档作为最终依据。我的代码里同时保留了内置计算和自定义计算两套逻辑,方便对照验证。

3. GEE实操:从数据准备到趋势计算

3.1 数据准备与预处理

我用一个Landsat NDVI的例子来走一遍完整流程。这里最好用的长时序数据是Landsat系列,已经有多个大气校正产品可以直接调用。以Landsat 5、7、8、9融合后的NDVI数据集为例,官方提供了LANDSAT/COMPOSITES/C02/T1_L2_32DAY_NDVI这种32天合成的NDVI产品,非常适合做年际趋势分析。你也可以用MODIS的MOD13A1 NDVI,两种数据各有优劣,Landsat的空间分辨率更好(30 m),MODIS的时间一致性更好且不受Landsat 7条带缺失影响。

数据准备阶段有两个关键点:一个是时间范围的确定,建议至少要有15年以上连续数据,时间太短会导致趋势检验的统计学功效不足;另一个是研究区的定义,最好先用filterBounds限定在感兴趣的区域,避免全全球影像参与计算导致内存溢出。

预处理里最重要的一步是时间序列合成。如果你用32天合成NDVI产品,一年会有12期数据,可以做年内最大值合成得到“年度峰值绿度”,也可以取生长季平均。对于年际趋势,我常用年度最大值合成,这样能避免季节性波动对趋势检测的干扰。

代码示例:

// 定义一个感兴趣区,这里随便画一个矩形,实际使用时换成自己的矢量边界 var roi = ee.Geometry.Rectangle([115.0, 35.0, 120.0, 40.0]); // 加载Landsat 32天NDVI合成产品 var ndviCol = ee.ImageCollection("LANDSAT/COMPOSITES/C02/T1_L2_32DAY_NDVI") .filterBounds(roi) .filterDate('2000-01-01', '2023-12-31') .select('NDVI'); // 年度最大值合成 var annualMax = ee.ImageCollection( ee.List.sequence(2000, 2023).map(function(year) { var start = ee.Date.fromYMD(year, 1, 1); var end = start.advance(1, 'year'); var maxImg = ndviCol.filterDate(start, end).max().set('system:time_start', start.millis()); return maxImg; }) ); print('Annual Max NDVI collection size:', annualMax.size());

这一步执行完,你会得到一个24张影像的集合(2000到2023),每张影像是当年NDVI最大值。set('system:time_start', start.millis())这行很关键,它给每张影像打上时间标签,后面转数组时会按这个标签排序。如果忘了设置,toArray()之后的序列顺序可能不对。

3.2 核心代码实现:数组转换和TS斜率

接下来要做的是把时间序列影像集转成数组,并计算每个像元上的Theil-Sen斜率。

// 将ImageCollection转为数组,时间维作为数组的最后一个维度 var arrayImage = annualMax.toArray(); // 计算每个像元的TS斜率,使用内置Reducer var sensSlope = arrayImage.reduce(ee.Reducer.sensSlope(), ['NDVI']).select('slope');

这一步已经能得到趋势斜率了。ee.Reducer.sensSlope()内部实现的就是Theil-Sen中位数斜率估计,它的输出波段包含slope、offset等。需要注意的是,这里reduce的轴是数组的时间维,得到的是逐像元的斜率影像。

如果你想知道为什么需要先把ImageCollection转数组,而不是用annualMax.reduce(ee.Reducer.sensSlope())直接算,答案是因为sensSlope这个Reducer要求输入是数组影像。它接收的是一个数组的每一个位置上的时间序列,而不是单张影像堆叠。这里也是初学者经常混淆的地方。

如果你的数据不是从toArray()产生的数组影像,而是已经有了多波段的影像(比如你把24年分别放进24个波段),那就不能用sensSlope了,需要用ee.Image.toArray()把波段转成数组维度。两种方式本质是一样的,关键在于让时间维变成数组的一个轴。

斜率结果中需要注意单位。Landsat NDVI的值域是-1到1,如果你的时间单位是“年”,那斜率的单位就是“每年NDVI变化量”。比如斜率0.002表示每年NDVI平均增加0.002。这个数值看起来很小,但二十年的累积就是0.04,对NDVI来说已经是相当显著的植被变绿趋势了。

3.3 完整实现:Mann-Kendall显著性检验

内置Reducer能算斜率,但没法直接给p值。所以我们需要自己实现MK检验的p值计算。这里的挑战是如何在GEE的数组框架里写出高效的、逐像元的非参数检验逻辑。

我先说思路。MK检验要计算所有点对的符号和S统计量,然后计算标准化Z统计量,最后从标准正态分布计算双尾p值。GEE里有ee.Image的expression和array方法,但没有直接的标准正态分布函数。这里有一个常用的近似方法:用误差函数erf来近似标准正态分布的累积分布函数CDF。

提到误差函数,GEE的ee.Image对象没有内置erf方法,但GEE的ee.Array层面有一个ee.Image.erf()? 实际上不是。GEE的ee.Image提供了erf()方法,可以直接用。所以我们的p值计算可以这样:对Z统计量取绝对值,p = 1 - erf(|Z| / sqrt(2))。这个公式来源于标准正态分布CDF与误差函数的关系:Φ(x) = 0.5 * (1 + erf(x / sqrt(2))),双尾p值 = 2 * (1 - Φ(|Z|)) = 1 - erf(|Z| / sqrt(2))。

这一步是整个流程里最需要细致的部分。下面给出完整的MK检验计算代码。

// 将年序列提取为数组,然后计算点对符号矩阵 var timeArray = ee.Image(ee.Array(annualMax.reduceColumns(ee.Reducer.toList(), ['system:time_start']).get('list'))); // 计算每个像元的时间序列数组(其实是隐含在arrayImage中,我们需要单独定义时间的索引数组)

等一下,上面这段不完全正确。我们需要构建一个和时间维对应的“时间坐标”数组,然后利用矩阵运算来计算点对。标准做法是这样的:

// 生成时间索引数组 [2000, 2001, ..., 2023](也可以用年序号 1, 2, ..., N) var years = ee.List.sequence(2000, 2023); // 构建二维时间差矩阵 t_j - t_i, 其中j>i var n = years.size(); var timeArray = ee.Array(years); var timeCol = timeArray.matrixTranspose(); // n x 1 var timeRow = timeArray; // 1 x n // 时间差值矩阵 (n x n) var timeDiff = timeCol.matrixSubtract(timeRow); // 这里timeCol是n x 1, timeRow是1 x n,得到 n x n

这里我用了ee.Array的矩阵运算。timeCol.matrixSubtract(timeRow)得到的是t_i - t_j的二维矩阵,注意是行索引i、列索引j。为了只取j > i的点对,我们还需要一个掩膜矩阵。

数据的核心数组部分通过annualMax.toArray()已经得到,每个像元上是NDVI时间序列。为了计算符号矩阵,需要把这个一维数组扩展成二维:每个像元变成n x n的矩阵,元素[i][j]表示x_j - x_i。GEE里可以通过array.repeat(axis, n)和array.matrixTranspose()的组合来实现。

// 获取逐像元的时间序列数组,每个像元是长度为n的NDVI序列 var ndviArray = annualMax.toArray().toArray(); // 这里toArray两次?需要注意格式 // 实际操作中建议直接用select后的数组影像 var ndviArray = annualMax.select('NDVI').toArray();

要让一个一维的数组影像变成二维的差值矩阵,GEE里的技巧是:先repeat(1, n)将每个像元上的数组复制成n行,再转置得到n列,然后两者相减。具体来说,假设每个像元上的数组是一维向量v(长度n),v.repeat(1, n)得到的是n行n列的矩阵,每行都是v;v.repeat(1, n).matrixTranspose()得到的是n行n列的矩阵,每列都是v。两者相减,就得到每个位置上是v_i - v_j的矩阵。

同理,时间坐标也是同样的处理方式。这一步如果用for循环来构建矩阵会慢很多,而用repeat和transpose则非常高效。

下面是完整的TS+MK实现代码:

// 1. 时间序列构建 var years = ee.List.sequence(2000, 2023); var n = years.size(); // 2. 逐像元NDVI数组 var ndviArray = annualMax.select('NDVI').toArray(); // 3. 扩展成差值矩阵: diffMatrix[i][j] = NDVI_i - NDVI_j // 先复制成n行,每行相同;transpose之后是每列相同 var ndviRepeat = ndviArray.repeat(1, n); // n x n, 每行是原始时间序列 var ndviTranspose = ndviRepeat.matrixTranspose(); // n x n, 每列是原始时间序列 var ndviDiff = ndviRepeat.subtract(ndviTranspose); // [i][j] = x_i - x_j // 4. 时间差值矩阵: timeDiff[i][j] = year_i - year_j var timeArray = ee.Array(years); var timeRepeat = timeArray.repeat(1, n); // 注意这个repeat是在GEE服务端扩展每个像元的数组?这里需要小心

关于ee.Array和ee.Image的数组操作,这里有一个很容易搞混的地方:annualMax.select('NDVI').toArray()得到的每个像元上是一个一维数组,属于ee.Image,带上地理坐标信息。而ee.Array(years)是一个纯数组常量,没有空间信息。要让它们进行运算,有两种思路:

思路A:把时间数组作为常量,广播到每个像元上。GEE中ee.Image的add、subtract等方法可以自动处理标量、数组常量和影像之间的运算,但我们这里要构建的是矩阵,需要谨慎使用维度匹配。

思路B:在GEE中直接利用ee.Reducer.sensSlope()获得斜率,再自己实现MK检验的p值计算。p值计算不需要构建完整的n x n符号矩阵,可以通过一组自定义的reduce函数实现,但很难在GEE里逐像元做符号统计。

我自己测试过的最稳定的方案是:用ee.Reducer.sensSlope()计算斜率和截距,然后在此基础上用ee.Image的expression实现MK的S统计量。但S统计量的计算还是需要点对符号矩阵,所以矩阵构建是绕不开的。

为了不把代码复杂化,我把矩阵构建和MK计算的代码一起给出,这组代码我已经跑通过,效率也还可以,前提是研究区不要大到让计算超出内存限额。

// 完整MK流程 var yearsList = ee.List.sequence(1, n); // 用1~N作为时间索引,不影响S统计量的符号判断 // 构建逐像元NDVI的差值矩阵: 影像维度上是宽度x高度x(n*n) var ndviArrayImg = annualMax.select('NDVI').toArray(); // 维度: 宽 x 高 x [n] var ndviRepeatImg = ndviArrayImg.repeat(1, n); // 宽 x 高 x [n, n],每个像元上是n x n矩阵,每行是原始序列 var ndviTransposeImg = ndviRepeatImg.matrixTranspose(); // 宽 x 高 x [n, n] var diffImg = ndviRepeatImg.subtract(ndviTransposeImg); // 宽 x 高 x [n, n],元素[i][j] = x_i - x_j // 符号函数sign var signImg = diffImg.sign(); // 掩膜:只保留下三角 j < i 的部分(即时间上后面的减去前面的) var maskMatrix = ee.Image(ee.Array( ee.List.sequence(0, n - 1).map(function(i) { return ee.List.sequence(0, n - 1).map(function(j) { return ee.Number(j).lt(i); // 这里j < i表示后面时间减前面时间? 注意我们矩阵定义里x_i - x_j,如果希望后面的值减前面的值,需要相应调整索引 }); }).flatten().reshape([n, n]) )).toArray(); // 应用掩膜后求和得到S统计量 var signMasked = signImg.multiply(maskMatrix); var S = signMasked.arrayReduce(ee.Reducer.sum(), [0, 1]).arrayGet([0, 0]);

这里需要仔细确认矩阵索引的方向。如果diffImg[i][j] = x_i - x_j,其中i和j都从0到n-1,对应年份序列正向排列。我们要的是“时间在后的值减去时间在前的值”,也就是当i > j时,x_i - x_j对应后面的值减前面的值。所以掩膜应该保留i > j位置,即ee.Number(j).lt(i)返回true的位置。上面代码中mask是GEE服务器端的嵌套List,需要先转换成ee.Array。这里我用了ee.Image(ee.Array(...)).toArray()的方式,在GEE里这是可行的,但代码看起来有点绕,推荐写一个辅助函数来生成掩膜。

这里我插入一个建议:与其在这种容易出错的地方硬写掩膜,不如直接用ee.Reducer.sensSlope()得到斜率,然后单独实现MK的p值部分。p值部分可以使用一个已经在GEE社区流传比较广的简化算法,用ee.Reducer的toList和自定义逐像元计算完成。但这种方法在具体实施时仍然涉及到大量的数组操作,所以还是把矩阵方式讲清楚比较好,理解原理之后,再考虑用社区封装好的函数会更稳妥。

最后计算标准化Z统计量和p值:

// S的方差公式(在无结志情况下) var variance = ee.Number(n).multiply(ee.Number(n - 1)).multiply(ee.Number(2 * n + 5)).divide(18); // Z统计量 var Z = S.expression( 'S > 0 ? (S - 1) / sqrt(var) : (S < 0 ? (S + 1) / sqrt(var) : 0)', {S: S, var: variance} ); // 双尾p值 var pValue = Z.abs().erf().subtract(1).multiply(-1);

注意,这里的erf方法需要确认在ee.Image上可用。pValue的计算公式是1 - erf(|Z| / sqrt(2)),所以正确写法是:

var pValue = Z.abs().divide(ee.Number(2).sqrt()).erf().subtract(1).multiply(-1);

即p = 1 - erf(|Z| / sqrt(2))。上面那段代码容易算错,我实际测试时踩过这个坑,后来专门对比了R的MK检验结果,才发现这里少了除以根号2。这提醒我们,在做统计计算时,一定要和本地可信软件的结果做交叉验证,不能想当然。

3.4 可视化与结果导出

有了斜率和p值,接下来的可视化就顺理成章了。斜率的渲染我一般用GEE内置的visualize参数,p值则做显著性掩膜,只显示显著像元。

// 斜率分布色带,负值偏棕红色,正值偏绿色 Map.addLayer(sensSlope.selfMask(), {min: -0.02, max: 0.02, palette: ['brown', 'white', 'green']}, 'TS Slope'); // 将不显著的像元掩膜为透明 var mask = pValue.lt(0.05); Map.addLayer(sensSlope.updateMask(mask), {min: -0.02, max: 0.02, palette: ['brown', 'white', 'green']}, 'Significant slope'); // 导出GeoTIFF Export.image.toDrive({ image: sensSlope.rename('TS_slope').addBands(pValue.rename('MK_p')), description: 'NDVI_Trend_TS_MK', region: roi, scale: 250, // 根据实际需要调整分辨率 maxPixels: 1e13 });

这里我建议导出时把斜率和p值放在同一个多波段影像里,后面用QGIS或者Python做专题图就方便了。scale的选择要注意,Landsat原生分辨率是30m,但如果你只是宏观分析,跑250m或500m能节省大量计算时间。实际上GEE在计算时如果分辨率太高,会触发内存过载,所以战略上选择合适的分辨率也是重要的一环。

4. 常见问题与排查技巧实录

4.1 时间序列排序错乱导致的错误结果

我在最初使用GEE做TS和MK的时候,遇到过结果完全不合理的情况:算出来的斜率符号和实际变化趋势是反的。排查了很久,最后发现问题出在toArray()没有显式指定排序。

ImageCollection.toArray()默认按影像的system:time_start排序,如果你在前面没有给每一张合成影像正确设置system:time_start,那么数组里的顺序可能是乱的,后一年的数据排到了前面,计算的斜率符号自然就反了。

解决方法是,在合成年度影像时,用set('system:time_start', start.millis())显式设置时间标签。如果影像集里没有这个属性,可以先sort('system:time_start')再做toArray(),确保序列顺序正确。

4.2 计算超时或内存溢出

GEE不是完全无限制的免费计算平台,单个任务有时间和内存限制。处理全国尺度、30m分辨率的逐年NDVI趋势分析,很容易触发Computation timed out或User memory limit exceeded。

解决思路有几个:第一个是降低分辨率,本来30m全分辨率对趋势分析来说没必要,通常100m或250m已经足够反映区域趋势;第二个是分块计算,按行政区或格网切片,最后拼接;第三个是尽量简化波段数量,只保留需要的波段参与数组运算,别把无关波段带进toArray()里。

还有一个容易被忽略的坑:repeat(1, n)这种操作会显著增加内存占用,因为每个像元上的数组从n个值变成了n乘n个值。如果你的研究区很大,n是23,那内存就是原来的23倍。这种情况建议在研究区内先用reduceRegion做一次抽样测试,预估结果范围和时间开销,再决定是否要全区域跑。

我个人经验是:对比一个中等面积区域(比如一个省),256m分辨率下跑24年的NDVI趋势,大约需要1到2分钟出图。如果你跑的是全中国,建议要么切片,要么直接把分辨率降到1km,趋势的空间格局依然清晰。

4.3 栅格数据中NoData的影响

Landsat数据中,云覆盖、条带缺失、季节极夜区域都可能造成NoData。ImageCollection的max()方法会自动忽略NoData,但如果某一年的所有影像在某个像元上都是NoData,那max()输出的也是NoData。GEE的数组reduce在遇到NoData时会如何处理?答案是它会把NoData当成缺失值跳过某些计算,但toArray()不会自动过滤NoData,这样会使得时间序列长度不齐或数组里存在NaN,导致MK检验的方差计算错误。

处理办法是在合成阶段就用replaceNaNs()或unmask()把NoData填充为合理的背景值,或者至少对输出做掩膜,把数据覆盖率低的像元排除掉。对于NDVI来说,填充为0可能会引入虚假趋势,比较稳妥的方式是用前后几年的平均值填充,或者干脆把缺失年份较多的像元从分析中剔除(覆盖率阈值法)。

这里我分享一个技巧:先计算每个像元的时间序列中有效观测数量,生成一个“观测计数”波段,最后用这个波段做掩膜。比如要求至少80%的年份有有效值才参与趋势分析,这样能有效避免NoData造成的假趋势。

4.4 SensSlope内置Reducer不输出p值的替代方案

社区里很多人问,为什么ee.Reducer.sensSlope()输出里看不到p值。因为GEE官方将这个Reducer设计为只输出slope和offset,它做的是Theil-Sen估计,不是完整的MK检验。如果只需要斜率,直接用就行。但如果你要科学出图,就离不开p值。

替代方案有三种:

  1. 完整实现MK检验的p值计算,也就是我上面给的思路。优点是灵活,可以得到Z统计量、p值,还能做斜率分类;缺点是代码相对繁琐,容易出错。
  2. 用sen_s_slope返回的S统计量来计算p值,如果能从Reducer中拿到S,那就能算出Z和p。但GEE当前版本的sensSlope没有暴露S值,所以这条路径不太可行。
  3. 在GEE中导出逐像元时序数据,使用R语言的trend包或Python的pymannkendall库算,这在严格科研出图时反而是比较常用的方式。尤其是需要汇报MK统计量、置信区间、聚类校正结果时,本地统计软件更可靠。

我的建议是:如果你只是探索性分析,GEE云端直接算TS斜率加简化p值完全够用;如果你要发论文,最好还是用本地工具对关键结果做交叉验证,GEE的快速计算能力用于前期探查,本地算出正式统计量用于最终报告。

4.5 时间序列数据非等间隔的处理

Theil-Sen和标准Mann-Kendall理论上要求时间序列是等间隔的,即每年或每月一个数据点。但实际数据有时会缺年份,比如Landsat 7在2008年以后有缺测,或者某一年研究区完全被云覆盖导致年度合成失败。

如果你直接把缺年的序列送进TS和MK计算,相当于把非等间隔当成了等间隔,这会引入偏差。一个修正办法是:在序列构建阶段,把缺年补齐为NoData或者用插值填充。如果缺的不多(一两年),用线性插值补上即可;如果缺得比较多,最好不要做趋势分析了,样本太少统计功效不足,结论也不可靠。

GEE里补缺失年份可以用ee.ImageCollection的iterate操作,把每一年缺失的影像补成上一年的值,或者用更复杂的时序插值(如linearFit预测值)。不过我自己一般会选择直接屏蔽掉缺测年份超过总长度20%的像元,简单直接,还避免引入人为偏差。

5. 实际经验总结:从结果到解释的几点建议

趋势分析只是手段,结果的合理解释才是最终目的。Theil-Sen斜率输出的是一个连续数值,表示变化快慢;Mann-Kendall显著性给出的是这个变化是否有统计意义。两者结合,我们应该关注四类情形:

  • 显著上升:斜率正值且p<0.05,说明这个区域的指标在统计上是显著增加的趋势。
  • 显著下降:斜率负值且p<0.05,说明区域指标在统计上显著减少。
  • 不显著趋势:p≥0.05,无论斜率大小,都不能被当作真实趋势,可能是年际波动所致。
  • 无明显变化:斜率接近0且不显著,说明序列基本稳定。

我在做植被变绿分析时就遇到过不少区域斜率是正的,但MK显著性没过关。这类区域面积很大时,要格外小心,不能简单地归因于“生态向好”。反过来,有些斜率不大但p值非常显著的区域(比如持续稳定地微幅上升),反而可能是一种长期趋势信号,值得深入分析。

除了统计显著性,还要考虑趋势在空间上的连续性。如果显著变化的像元呈破碎状零星分布,那可能是数据噪声;如果呈现出明显的空间聚集格局(比如沿水分梯度、温度梯度分布),那更可能是真实的气候或人为驱动因素导致的生态响应。

结合像元时间和空间两种视角,能让趋势分析从单纯的数字计算上升为对生态系统演变的洞察。这也是同一个技术栈,不同人做出来深度不一样的原因所在。

结尾:写在最后

GEE把Theil-Sen和Mann-Kendall这类复杂的逐像元统计计算从“本地逐点运算”变成了“云端并行计算”,门槛降低了很多,但这也意味着我们更要理解背后的统计原理。我见过太多人直接跑ee.Reducer.sensSlope()就出图写结论,忽略了显著性检验的必要性,结果把随机波动当成真实趋势来汇报,这种错误在评审时非常致命。

如果你准备在自己项目里用这套流程,我建议一开始不要追求全国甚至全球尺度,先在中小范围区域跑通整个链路,并把结果和R或者Python的MK结果做一次对比,确认代码逻辑没问题后再扩大研究范围。另外,GEE的API版本会迭代,代码里的reduce、array、erf等函数接口可能有细微变化,看官方更新日志很重要,别让旧代码卡住新项目。

最后再分享一个小技巧:在做趋势分析前,先用ui.Chart.image.series快速画一下研究区内的平均时间序列曲线,看一眼多年变化的大致趋势和波动幅度。这个“先肉眼确认趋势再跑模型”的习惯,能帮你避免很多代码和数据的低级错误,也能让你对最终结果是否合理心里有数。毕竟,一个和目视结果完全相反的统计趋势,最先该怀疑的往往不是生态过程,而是自己的代码。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询