做遥感时间序列分析的人,大概率都跟NPP打过交道。净初级生产力(Net Primary Productivity, NPP)是评估植被固碳能力和生态系统健康状况的核心指标,MODIS的MOD17A3产品可以稳定提供2001年至今逐年、500米分辨率的全球NPP数据。数据拿到手之后,大家问得最多的一个问题就是:这二十几年,我研究区里的植被生产力到底是在变好还是变差?回答这个问题,目前学术圈用得最广、审稿人也最认的一套工具,就是Theil-Sen Median斜率估计配合Mann-Kendall趋势检验。这套组合在NDVI、LAI、GPP、NPP等各类植被参数的趋势分析里几乎成了标配,但说句实话,能把原理讲明白、把代码一次跑通、把结果正确解读出来的人并不多。这篇文章就以多年NPP数据为例,把算法原理、数据预处理、Python实现、结果出图和避坑经验一条龙讲清楚,不管是刚入门的研究生,还是已经在处理遥感大数据、需要批量产出趋势图的从业者,都能直接照着操作。
1. 为什么不用普通线性回归:项目思路拆解
1.1 NPP趋势分析到底在回答什么问题
做NPP趋势分析的动机通常很朴素:研究区这些年植被固碳能力是在增强还是减弱?气候变化背景下,温度升高、降水格局改变,植被生产力是否出现了可检测的方向性变化?这类问题看起来简单,实际分析时却绕不开三个核心指标:变化方向(增加还是减少)、变化幅度(每年变化多少)、显著性(这个变化是否可信)。
如果手里只有两个时间点的NPP,做差值就够了,但像MOD17A3这种从2001年连续出到今天的年度产品,已经积累了超过20期。时间序列的优势在于可以分离出"长期趋势"和"年际波动",比如某一年因为极端干旱导致的NPP暴跌,并不代表植被在持续退化;反过来,某一年风调雨顺出现的NPP峰值,也不能证明生态系统在变好。趋势分析要做的是从这些波动中提取稳定信号。NPP趋势结果通常直接服务于生态修复成效评估、碳汇潜力计算、自然保护区管理以及模型验证等工作,所以分析结果的稳健性非常重要,不能因为一两个极端年份就把结论带偏。
1.2 Theil-Sen + Mann-Kendall组合的优势
先说说为什么不直接用Excel里那个普通最小二乘线性回归。NPP时间序列有几个特征:第一,非正态,植被生产力受降水、温度、病虫害、火灾等影响,年与年之间经常出现厚尾分布;第二,存在极端值,比如异常干旱年份NPP骤降,这些点对最小二乘拟合影响极大;第三,样本量往往只有十几到二十几个,小样本下更不满足回归分析对误差正态性和方差齐性的要求。普通线性回归在这些条件下,斜率的估计值很容易被一两个离群点带偏,显著性检验也会失真。
Theil-Sen Median斜率估计和Mann-Kendall检验都是非参数方法,不假设数据服从特定分布,对离群值天然免疫。Theil-Sen的核心思想是计算所有数据点两两之间的斜率,然后取中位数作为整体趋势斜率,这相当于把"一个极端点"对结果的影响降到了最低。Mann-Kendall检验则是基于数据的秩次而不是原始数值,通过比较所有前后样本对的大小关系来判断是否存在单调趋势,即使序列里有部分缺测值也能处理,而且它检验的是稳健的单调趋势,不要求趋势一定是直线。这两个方法配合使用,一个给变化幅度,一个给显著性判断,互补性非常强,这也是我在实际项目中处理几十个像元、几十幅影像时首选这套组合的原因。
2. 两个核心算法的原理,这次彻底讲清楚
2.1 Theil-Sen:取中位数而不是最小二乘
Theil-Sen斜率估计的原理一句话就能概括:对时间序列上的任意两个点,用纵坐标之差除以时间间隔得到一条斜率,把所有两两组合的斜率取中位数,就是序列的整体趋势斜率。假设有两个年份i和j,对应的NPP值为y_i和y_j,那么这两点间的斜率为:
slope_ij = (y_j - y_i) / (j - i)
对n期数据,一共有n(n-1)/2个这样的斜率,Sen's slope就是这些斜率的中位数。比如有5年数据,n(n-1)/2 = 10个斜率,把这10个值从小到大排列,取第5和第6个的平均值(偶数个时),就是最终斜率。这个斜率代表每单位时间(通常是每一年)NPP的变化量,单位是g C/m²/yr(每年)。
为什么取中位数这么管用?还是拿5年数据举例子,假设其中一年NPP因为严重干旱从500掉到了200,普通最小二乘会把这一年当成真实现象去拟合,斜率会被明显拉低;但Theil-Sen只需要这一个点对参与形成的若干斜率被其他正常年份形成的斜率"中和"掉,中位数天然不受少数极端斜率影响。这种稳健性还有一个"副产品":我不需要对数据做平滑、剔除异常值等预处理,减少了主观干预,结果可重复性也更高。
2.2 Mann-Kendall:从秩次角度看趋势
Mann-Kendall检验本质上是在回答一个问题:这个序列是否存在单调上升或下降的趋势?它不关心中间的波动到底有多大,只看数据的相对顺序。具体做法是计算统计量S:
S = Σ_{k=1}^{n-1} Σ_{j=k+1}^{n} sign(x_j - x_k)
其中sign函数在x_j大于x_k时取1,相等时取0,小于时取-1。如果序列整体在上升,后面的值普遍比前面的值大,S就是较大的正数;如果整体下降,S是较大的负数;如果没有趋势,S会接近0。
但S本身的大小受样本量影响,不好直接用。当n大于10时,可以用正态近似把S标准化为Z统计量,需要先计算S的方差:
Var(S) = [n(n-1)(2n+5) - Σ t_i(t_i-1)(2t_i+5)] / 18
其中t_i是第i组相同值的个数,这个修正项用于处理序列中出现的"结"(相等值)。然后用连续性修正公式计算Z值:S大于0时,Z = (S-1)/√Var(S);S小于0时,Z = (S+1)/√Var(S);S等于0时Z等于0。最后通过标准正态分布算出双尾p值。原假设H0是"序列无单调趋势",如果p值小于显著性水平(通常取0.05),就拒绝原假设,认为趋势显著。
有个细节值得注意:MK检验检验的是"单调趋势",它不要求趋势是直线。只要数据持续上升,哪怕上升速率在变化,MK也能识别出来,这对生态数据其实特别友好,因为NPP的长期变化往往受多种因素叠加,很难是严格线性关系。
2.3 斜率与显著性合起来怎么看
单独看Sen斜率,可能得到一个正数,但不知道这个正数是不是噪声导致的;单独看MK检验的p值,知道趋势显著,但不知道变化幅度到底有多大。所以标准做法是两者结合。
| 判断结果 | 条件 | 含义 |
|---|---|---|
| 显著增加 | Sen斜率 > 0 且 p < 0.05 | NPP在显著上升,生态状况改善 |
| 不显著增加 | Sen斜率 > 0 且 p ≥ 0.05 | 有上升迹象,但证据不足 |
| 稳定 | Sen斜率 = 0 或接近0 | 无明显变化 |
| 不显著减少 | Sen斜率 < 0 且 p ≥ 0.05 | 有下降迹象,但证据不足 |
| 显著减少 | Sen斜率 < 0 且 p < 0.05 | NPP在显著下降,需要警惕 |
这套分级方法在论文里非常常见。我在实际项目中还会加一步:如果研究区很大,几十万个像元同时做检验,会出现多重比较问题,即纯噪声数据中也会有一定比例的假显著性。这种情况下可以再做一次FDR(Benjamini-Hochberg)校正,对p值这一列做整体调整,再判断显著性。虽然很多论文不做这一步,但审稿人问到的时候,做了就比没做主动。
3. 多年NPP数据:来源选择与预处理
3.1 NPP数据源怎么选
目前做年度NPP趋势分析,最常用的就是MODIS的MOD17A3HGF产品。这个产品提供2001年至今的逐年全球NPP,空间分辨率500米,单位是kg C/m²/yr,存储时乘以了10000的缩放系数,也就是说影像里的DN值乘以0.0001才等于真实NPP值。MOD17A3HGF在MOD17A3的基础上用了HGF(High-resolution daily Gap-Filled)算法,对受云污染的遥感输入做了插补,连续性更好,是趋势分析的首选。
如果需要更长时间序列,可以选用GIMMS3g NDVI反演的NPP数据,能够追溯到1981年,但空间分辨率只有8公里,适合大区域宏观分析,不适合县级这类小尺度研究。还有一些人用BEPS、CASA等模型自己估算NPP,灵活性高,但需要大量气象和遥感输入数据,工作量会明显增加。我个人的经验是:先明确研究区尺度,省域以上用粗分辨率长序列做宏观判断,县市级和具体样地用MOD17A3HGF的500米产品,两者结论可以相互印证。
3.2 下载与预处理:从原始分片到标准时间序列
MOD17A3HGF是按全球瓦片发布的(比如h26v04、h27v04),下载方式主要有三种:NASA AppEEARS在线提取、USGS EarthExplorer下载原始瓦片、Google Earth Engine直接filter导出。研究区跨多个瓦片时需要先拼接再裁剪。下载时建议直接把QC质量控制波段一起下载,后面用得着。
拿到每一年NPP影像之后,预处理顺序基本是这样:先检查投影和坐标系,统一到WGS84或研究区所在的投影坐标系;然后裁剪到研究区边界;接着读像元值并乘以0.0001还原成真实NPP(单位kg C/m²/yr),如果要跟文献里常见的g C/m²/yr对齐,再乘1000;最后把多年影像按时间顺序堆叠成一个三维数组(n_year, rows, cols),方便后续逐像元计算。
这里有个特别容易翻车的点:MOD17A3HGF的单位是kg C/m²/yr,很多文献里的NPP趋势斜率单位是g C/m²/yr²,如果不做单位换算,结果会差1000倍,趋势图上的颜色分级完全没法跟别人的研究对比。我习惯一开始就把单位统一换算成g C/m²/yr,后面所有中间结果都带单位标注,省得事后返工。
3.3 数据质量控制与异常值处理
MOD17A3HGF虽然做了gap-filling,但个别像元在多年里仍然可能出现异常值或无效值。质量控制的第一步是结合QC波段,把质量差、填充比例过高的像元标记为无效;第二步是结合土地利用数据,把水体、城市、裸地等不生长植被的区域直接掩膜掉,这些像元的NPP本身没有生态学意义,计算趋势不但浪费算力,还会让统计结果失真。
时间序列中的极端单年值要谨慎处理。比如研究区某年发生严重干旱,NPP只有正常年份的一半,这个点从数据质量角度是真实的,不能简单剔除,否则人为制造"趋势"。正确的做法是保留真实极端值,让Theil-Sen和MK这两个稳健方法自己去"消化"。但如果发现某个像元出现物理上不可能的数值(比如NPP为负、数值跳变量异常大),就要仔细检查是不是数据配准或拼接出了问题。一般情况下,单像元有效年份低于10年的,我建议直接不计算趋势,样本太少算出来的结果没有统计意义,出图时显示为空白即可。
4. Python全流程实操:从单点验证到逐像元批量
4.1 环境准备与依赖安装
整个流程用到的库不算多,核心是科学计算和栅格读写两块。我推荐直接创建一个干净的conda环境,然后安装下面这些依赖:
pip install numpy pandas scipy matplotlib rasterio pymannkendall joblib其中pymannkendall是专门做多种Mann-Kendall变体检验的库,比手搓MK方便得多,后面会用到。scipy提供了现成的theilslopes函数,不用自己写两两斜率循环。rasterio负责读写GeoTIFF,joblib用来做并行加速。版本方面,我目前用的是Python 3.10、numpy 1.24、scipy 1.10,都比较稳定。
4.2 单点时间序列验证:先跑通一个像元
批处理之前,一定要先拿一个像元把流程验一遍,不然几十万像元跑完才发现错误,代价太大。假设我已经把研究区某像元2001年到2020年的NPP逐年值读出来了,单位是g C/m²/yr:
import numpy as np from scipy import stats import pymannkendall as mk # 模拟一个像元的20年NPP序列,单位 g C/m2/yr years = np.arange(2001, 2021) npp = np.array([612, 608, 631, 645, 602, 655, 668, 672, 659, 681, 690, 672, 685, 701, 716, 708, 725, 731, 740, 729]) # Theil-Sen斜率估计 res = stats.theilslopes(npp, years) sen_slope = res.slope print(f"Sen's slope = {sen_slope:.3f} (g C/m2/yr per year)") print(f"截距 = {res.intercept:.2f}") # Mann-Kendall趋势检验 mk_result = mk.original_test(npp) print(mk_result)这段代码的输出里,mk_result会给出trend(趋势方向)、h(是否拒绝原假设)、p(p值)、z(标准化统计量)、Tau(Kendall tau)、s(S统计量)、var_s(S方差)、slope和intercept。拿到结果后,先仔细看看p值和slope是否符合直觉,再开始批处理。比如上面这个模拟序列整体是上升的,Sen斜率大约5.73,p值远小于0.05,trend显示increasing,h为True,这就是一个"显著增加"的像元。
scipy的theilslopes会自动处理数据中的NaN,但pymannkendall的original_test不会自动剔除NaN,所以在批量处理时,我会先对每个像元把NaN滤掉,确保传入的序列是干净且按时间顺序排列的。
4.3 全区域逐像元批量计算:向量化与并行
有了单点验证的基础,接下来就是把计算扩展到全区域。最笨的写法是两层for循环逐像元调用pymannkendall,好处是代码简单,坏处是慢得离谱。以1000×1000的像元矩阵为例,100万个像元每个都要做一次Theil-Sen加MK,纯Python循环跑一天都跑不完。
更聪明的做法是利用numpy的向量化能力。Theil-Sen斜率本质上是对所有两两差分的操作,MK的S统计量也是对两两符号差求和,这两种操作都可以转成矩阵运算。我先写一个向量化的Theil-Sen斜率函数:
def theil_sen_slope_vectorized(data2d): """ 对每个像元(列)计算Theil-Sen斜率 data2d: (n_time, n_pixels) 的二维数组 """ n_time, n_pixels = data2d.shape idx = np.arange(n_time) i_idx, j_idx = np.meshgrid(idx, idx, indexing='ij') valid_pair = j_idx > i_idx i_flat = i_idx[valid_pair] j_flat = j_idx[valid_pair] denom = (j_flat - i_flat).astype(np.float64) # 所有两两差分:每行是一对时间点,每列是一个像元 diffs = data2d[j_flat, :] - data2d[i_flat, :] slopes = diffs / denom[:, None] slopes = np.where(np.isfinite(slopes), slopes, np.nan) sen_slope = np.nanmedian(slopes, axis=0) return sen_slopeMK的S统计量同样可以向量化:
def mk_s_statistic_vectorized(data2d): """ 计算每个像元的Mann-Kendall S统计量 """ n_time, n_pixels = data2d.shape idx = np.arange(n_time) i_idx, j_idx = np.meshgrid(idx, idx, indexing='ij') valid_pair = j_idx > i_idx i_flat = i_idx[valid_pair] j_flat = j_idx[valid_pair] x_i = data2d[i_flat, :] x_j = data2d[j_flat, :] s = np.sum(np.sign(x_j - x_i), axis=0) return s有了S统计量以后,还需要计算方差、Z和p值。这里要注意一个细节:如果序列里有相等的值,需要做"结修正",否则方差不准确。为了处理结修正,我通常会保留一个对每个像元统计相同值数量的步骤,像元数很多时这个操作稍微有点慢,但比逐像元跑整个MK要快得多:
from scipy import stats as sp_stats def mk_z_p_value_vectorized(data2d): n_time, n_pixels = data2d.shape s = mk_s_statistic_vectorized(data2d) # 结修正项 tie_corr = np.zeros(n_pixels) for p in range(n_pixels): col = data2d[:, p] _, counts = np.unique(col, return_counts=True) counts = counts[counts > 1] if len(counts) > 0: tie_corr[p] = np.sum(counts * (counts - 1) * (2 * counts + 5)) var_s = (n_time * (n_time - 1) * (2 * n_time + 5) - tie_corr) / 18.0 var_s = np.maximum(var_s, 1e-12) z = np.zeros_like(s, dtype=np.float64) z[s > 0] = (s[s > 0] - 1) / np.sqrt(var_s[s > 0]) z[s < 0] = (s[s < 0] + 1) / np.sqrt(var_s[s < 0]) p = 2 * (1 - sp_stats.norm.cdf(np.abs(z))) return z, p向量化函数虽然快,但要注意内存。比如20期数据,两两组合有190对,100万个像元时,slopes矩阵是190×1000000,浮点64位就是1.5GB,很容易把内存吃光。我的办法是分块处理:每批读几万像元,用向量化函数算完,存结果,再读下一批。配合joblib做多进程并行,速度非常理想。实测下来,100万像元、20期数据,分块加并行大概几分钟就能跑完。
4.4 结果保存为GeoTIFF
计算得到sen_slope和p之后,下一步是保存成带地理信息的GeoTIFF,方便后续在ArcGIS、QGIS里出图。保存的关键是沿用原始的投影、地理范围和分辨率:
import rasterio def write_result(output_path, data, reference_tif): with rasterio.open(reference_tif) as src: profile = src.profile.copy() profile.update(dtype=rasterio.float32, count=1, compress='lzw', nodata=-9999) with rasterio.open(output_path, 'w', **profile) as dst: dst.write(data.astype(rasterio.float32), 1)我一般会同时输出三个文件:sen_slope.tif(趋势斜率)、mk_pvalue.tif(p值)、trend_class.tif(分级结果)。这三个文件是后续统计和出图的原材料。另外,提醒一句,保存之前记得把NaN像元统一替换成nodata值(比如-9999),不然很多后期处理软件不认NaN。
5. 趋势结果解读与出图
5.1 趋势等级划分标准
拿到slope和p值之后,不能直接拿slope去出图,因为slope的值域跨度大,颜色很难映射。我习惯先按生态含义分成五类,前面2.3的表格就是分类依据。具体代码:
trend_class = np.zeros_like(sen_slope, dtype=np.uint8) trend_class[(sen_slope > 0) & (p_value < 0.05)] = 1 # 显著增加 trend_class[(sen_slope > 0) & (p_value >= 0.05)] = 2 # 不显著增加 trend_class[(sen_slope == 0)] = 3 # 基本不变 trend_class[(sen_slope < 0) & (p_value >= 0.05)] = 4 # 不显著减少 trend_class[(sen_slope < 0) & (p_value < 0.05)] = 5 # 显著减少有时候研究区范围小、趋势比较一致,五类会出现某一类占比很小的情况,这时可以合并成三类:显著增加、显著减少、无明显趋势。但不管是几类,出图时一定要在图例里写清楚分类条件和显著性阈值,不能只给颜色,否则读者无法判断图的实际含义。
5.2 面积统计与分区分析
趋势图出来以后,我通常还会做一步定量统计,因为论文和报告里不能只放一张图,还得有一个"显著增加面积占研究区比例、显著减少面积占XXX%"的段落。用np.unique加上分类结果就能算面积占比:
import numpy as np unique, counts = np.unique(trend_class, return_counts=True) area_km2 = counts * 0.25 # 500米分辨率像元面积约0.25 km² total_area = area_km2.sum() for cls, cnt, area in zip(unique, counts, area_km2): print(f"类别{cls}: 面积 {area:.1f} km², 占比 {area/total_area*100:.2f}%")注意,这个0.25km²只有在等积投影下才严格成立,如果影像本身是经纬度坐标,像元面积随纬度变化,严谨做法是投影到Albers或Lambert等积投影后再统计。更进一步,我常常把趋势分类结果跟土地覆盖类型叠加分析,比如统计"林地里显著增加的像元有多少、草地退化面积有多大",这一步能直接把趋势分析结果落到生态管理场景里。做法很简单:对每个土地覆盖类型,单独统计各类趋势像元占比,然后画堆叠柱状图。
5.3 趋势结果的可视化建议
出图是很多人的痛处。我的经验是:二级专题图优先展示趋势分类结果,用顺序色或分类色表达五类,配一个研究区位置小图;一级研究图可以用连续slope作底图、用点画或透明度叠加显著性信息,或者直接展示slope和p的双变量图。绘图用matplotlib配合cartopy或basemap都可以:
import matplotlib.pyplot as plt from matplotlib.colors import ListedColormap cmap = ListedColormap(['#1a6e1a', '#8dd3a1', '#f7f7f7', '#f7b6b6', '#b2182b']) fig, ax = plt.subplots(figsize=(8, 8)) im = ax.imshow(trend_class, cmap=cmap, vmin=0.5, vmax=5.5) ax.set_title('NPP Trend Classification (2001-2020)')除了空间分布图,我还会选几个典型像元画出原始时间序列叠加Sen斜率线,这能非常直观地展示"统计量背后到底长什么样"。比如找一个显著增加和显著减少的像元各画一幅,读者对结果可信度会更有直观判断。唯一要注意的是,matplotlib默认颜色比较丑,出正式图前花点时间配置一下字体和中文字体支持。
6. 实操中遇到的那些坑(常见问题与排查技巧)
6.1 时间序列自相关导致"假显著"
这是我在做植被指数趋势分析时踩过最深的坑。Mann-Kendall检验假设样本之间相互独立,但NPP这种生态数据本身就有很强的年际持续性,比如湿润年份之后往往孕育了更好的植被条件,次年NPP也偏高。这种正自相关会让MK检验的方差被低估,p值偏小,明明没有真实趋势,检验结果却显示显著,这就是"假显著"。
解决办法有两类。一类是先用残差或预处理消除自相关,常用的有预漂白(Pre-Whitening)和趋势自由预漂白(TFPW,Trend-Free Pre-Whitening),原理是先估出趋势项并去掉,对剩余项做一阶自回归过滤,再把过滤后的残差和趋势项加回去重新检验。另一类是直接使用考虑自相关的修正MK检验,pymannkendall里带了一批现成的:
# 原版MK result_original = mk.original_test(npp) # 考虑自相关的Hamed & Rao修正MK result_modified = mk.hamed_rao_modification_test(npp) # 趋势自由预漂白MK result_tfpw = mk.trend_free_prewhitening_test(npp)我通常会在正式计算前先对研究区随机抽几百个像元,对比original_test和modified_test的结果,如果两者显著性差异很大,说明序列自相关明显,则全区域改用修正版本。虽然修正在算法上更稳妥,但论文中描述时也要写清楚用的是哪种版本,方便别人复现。
6.2 计算慢到怀疑人生怎么办
早期我用纯Python循环跑全区域趋势分析,1000×1000的像元矩阵跑了整整一晚上。后来总结下来,优化重点有三个:向量化、分块、并行。前面的4.3已经写了向量化思路,这里再说分块和并行的关键细节。
分块的核心是控制内存。假设20期数据两两组合190对,每批处理5000个像元,slopes矩阵只有190×5000×8字节约7.6MB,这个体量可以随便算。分块代码大致是:
from joblib import Parallel, delayed def process_chunk(chunk_data): # chunk_data: (n_time, n_chunk) slope = theil_sen_slope_vectorized(chunk_data) z, p = mk_z_p_value_vectorized(chunk_data) return slope, p # 将像元分成块 n_pixels = stack_3d.shape[1] * stack_3d.shape[2] pixels_2d = stack_3d.reshape(stack_3d.shape[0], -1) chunk_size = 5000 chunks = [pixels_2d[:, i:i+chunk_size] for i in range(0, n_pixels, chunk_size)] # 多进程并行 results = Parallel(n_jobs=8, verbose=1)(delayed(process_chunk)(c) for c in chunks)需要注意两点:一是mask掉无效像元后再做向量化,否则NaN会扩散;二是如果研究区面积不大,直接用scipy和pymannkendall逐像元算也行,没必要为了优化而优化。另外,有些区域数据已经发布在云端平台(比如大区域时序产品),直接在上面处理再下载结果,比本地跑更省事。
6.3 单位换算与坐标系翻车现场
NPP单位问题我之前提过,这里再强调一遍,因为实际项目中每次都有学生来问。MOD17A3HGF原始DN值需要乘以0.0001才是kg C/m²/yr,如果要转成g C/m²/yr,还要再乘1000。也就是说,DN值10000对应的NPP是1 kg C/m²/yr,也就是1000 g C/m²/yr。如果直接在DN值上算斜率,得到的斜率单位是"DN/年",跟文献里"g C/m²/yr²"完全没法比。我的经验是导入数据后第一时间完成单位换算,并将结果另存,之后所有脚本都基于换算后的数据运行。
坐标系是另一个高频翻车点。原始TIF可能是经纬度坐标(EPSG:4326),也可能是正弦投影,直接做面积统计和分区统计会得出错误结果。在进行面积占比统计前,一定要把数据重投影到研究区所在区域合适的等积投影。重投影本身会引入插值误差,所以最好在预处理阶段统一完成,而不是在已经计算完趋势之后再做投影。
6.4 时间长度与数据断点问题
MK检验的效果跟样本量高度相关。少于10年数据时,检验功效非常低,即使真实存在趋势也容易检不出来;而样本量越多年份越多,趋势判断越可靠。因此我一般建议至少使用15期以上的年度数据做分析,低于10年的结果不建议进入论文正式结果,可以作为初步探索。另外,如果研究区在时间段内出现过重大扰动(如火灾、大规模虫灾),NPP会先骤降再恢复,这种情况MK检验可能会被断点干扰,把所有年份当成一个整体来检验反而不合适。更严谨的做法是先做断点检测(比如BFAST方法),在分段基础上再算趋势,或者把扰动区单独出来分析。
我在实际做这类项目时还有一个个人习惯:无论分析结果如何,都把原始序列里每个像元的有效样本数输出一份,跟趋势结果放在一起检查。很多"异常趋势"其实是因为有效年份太少或某些年份数据缺失导致的,看到有效样本数分布图,很多数据问题一眼就能发现。这个习惯帮我避免了好几次把数据坑写成生态学结论的尴尬。
最后再分享一个小技巧:在跑全区域批量计算之前,永远先随机抽50到100个像元,把它们的原始时间序列、Sen斜率和MK结果全部打印出来人工检查一遍。我见过太多人直接把向量化函数甩上去跑了一晚上,最终发现是当年影像的波段顺序没对齐,或者某个年份的文件路径写错了,整个结果作废。花十分钟做抽查,比事后返工省出几十个小时。这套Theil-Sen加Mann-Kendall流程跑通之后,换一套数据、换一个研究区,改改路径和参数就能复用,是遥感时序分析里性价比极高的通用技能。