Non-Parametric Trend Analysis

Edit on GitHub
Report issue
Page history
Author(s): n-clinton
Trend analysis is finding places where something of interest is increasing or decreasing and by how much. More specifically, this tutorial demonstrates detecting monotonic trends in imagery using the non-parametric Mann-Kendall test for the presence of an increasing or decreasing trend and Sen's slope to quantify the magnitude of the trend (if one exists). The tutorial also shows estimating the variance of the Mann-Kendall test statistic, a Z-statistic for the test of presence of any trend, and a P-value of the statistic (assuming a normal distribution).

Important: the methods presented here are suitable for assessing monotonic trends (i.e. data without seasonality) in discrete data (i.e. not floating point). Additionally, if you apply the methods in this tutorial to new data (i.e. region, time frame, source) you may need to adjust the min and max visualization parameters to suit the particular results.

Time series data

We will use a time series of MODIS Enhanced Vegetation Index (EVI) from the MOD13A1 dataset. Each pixel of this image collection contains a time series and we will compute the stats in each pixel. Assume that filtering the collection to one season is sufficient to obtain time series with monotonic trends. To check the validity of that assumption for your area of interest, add the collection to the map and using the inspector, click some points and view the series chart presented in the console. Adjust the filter as necessary.

  var 
  
 mod13 
  
 = 
  
 ee 
 . 
 ImageCollection 
 ( 
 'MODIS/006/MOD13A1' 
 ); 
 var 
  
 coll 
  
 = 
  
 mod13 
 . 
 select 
 ( 
 'EVI' 
 ) 
  
 . 
 filter 
 ( 
 ee 
 . 
 Filter 
 . 
 calendarRange 
 ( 
 8 
 , 
  
 9 
 , 
  
 'month' 
 )); 
 Map 
 . 
 setCenter 
 ( 
 - 
 121.6 
 , 
  
 37.3 
 , 
  
 10 
 ); 
 Map 
 . 
 addLayer 
 ( 
 coll 
 , 
  
 { 
 min 
 : 
  
 - 
 500 
 , 
  
 max 
 : 
  
 5000 
 , 
  
 palette 
 : 
  
 [ 
 'white' 
 , 
  
 'green' 
 ]}, 
  
 'coll' 
 ); 
 

An example EVI time series (one pixel) from this collection is shown below. Is there a trend in this pixel? More importantly, is a there a statistically significant trend in this pixel? Read on to find out!

EVI time series in a single pixel

Join the time series to itself

The non-parametric stats of interest are computed by examining every possible ordered pair of unique values in the time series. If there are n time points in the series, we need to examine the n(n-1)/2 pairs (i, j) , i<j , where i and j are arbitrary time indices. (Here we use (i, j) to signify the pair of EVI images indexed by i and j ). To do that, join the collection to itself with a temporal filter. The temporal filter will pass all the chronologically later images. In the joined collection, each image will store all the images that come after it in an after property.

  var 
  
 afterFilter 
  
 = 
  
 ee 
 . 
 Filter 
 . 
 lessThan 
 ({ 
  
 leftField 
 : 
  
 'system:time_start' 
 , 
  
 rightField 
 : 
  
 'system:time_start' 
 }); 
 var 
  
 joined 
  
 = 
  
 ee 
 . 
 ImageCollection 
 ( 
 ee 
 . 
 Join 
 . 
 saveAll 
 ( 
 'after' 
 ). 
 apply 
 ({ 
  
 primary 
 : 
  
 coll 
 , 
  
 secondary 
 : 
  
 coll 
 , 
  
 condition 
 : 
  
 afterFilter 
 })); 
 

Mann-Kendall trend test

The Mann-Kendall trend is defined as the sum of the signs of all the pairs. The sign is 1 if EVI at time j is more than EVI at time i , -1 if the opposite is true and zero otherwise (if they're equal). Compute this by iterating over the collection and computing sign(i, j) for each image i in the collection and each image j in the after images.

  var 
  
 sign 
  
 = 
  
 function 
 ( 
 i 
 , 
  
 j 
 ) 
  
 { 
  
 // i and j are images 
  
 return 
  
 ee 
 . 
 Image 
 ( 
 j 
 ). 
 neq 
 ( 
 i 
 ) 
  
 // Zero case 
  
 . 
 multiply 
 ( 
 ee 
 . 
 Image 
 ( 
 j 
 ). 
 subtract 
 ( 
 i 
 ). 
 clamp 
 ( 
 - 
 1 
 , 
  
 1 
 )). 
 int 
 (); 
 }; 
 var 
  
 kendall 
  
 = 
  
 ee 
 . 
 ImageCollection 
 ( 
 joined 
 . 
 map 
 ( 
 function 
 ( 
 current 
 ) 
  
 { 
  
 var 
  
 afterCollection 
  
 = 
  
 ee 
 . 
 ImageCollection 
 . 
 fromImages 
 ( 
 current 
 . 
 get 
 ( 
 'after' 
 )); 
  
 return 
  
 afterCollection 
 . 
 map 
 ( 
 function 
 ( 
 image 
 ) 
  
 { 
  
 // The unmask is to prevent accumulation of masked pixels that 
  
 // result from the undefined case of when either current or image 
  
 // is masked.  It won't affect the sum, since it's unmasked to zero. 
  
 return 
  
 ee 
 . 
 Image 
 ( 
 sign 
 ( 
 current 
 , 
  
 image 
 )). 
 unmask 
 ( 
 0 
 ); 
  
 }); 
  
 // Set parallelScale to avoid User memory limit exceeded. 
 }). 
 flatten 
 ()). 
 reduce 
 ( 
 'sum' 
 , 
  
 2 
 ); 
 var 
  
 palette 
  
 = 
  
 [ 
 'red' 
 , 
  
 'white' 
 , 
  
 'green' 
 ]; 
 // Set min and max stretch visualization parameters as necessary. 
 Map 
 . 
 addLayer 
 ( 
 kendall 
 , 
  
 { 
 palette 
 : 
  
 palette 
 , 
  
 min 
 : 
  
 - 
 2800 
 , 
  
 max 
 : 
  
 2800 
 }, 
  
 'kendall' 
 ); 
 

Zoom to your area of interest and define a roughly symmetric color stretch using the map layers styling dialog (i.e. the mean of min and max should be approximately zero). The red pixels are decreasing trend and the green pixels are increasing trend. This is illustrated in the following image of the Mann-Kendall statistic for an area of California, USA. The map pin is at the approximate location of the Googleplex. The dot is the location of the point from which the time series shown above was extracted. We'd like to identify which pixels in this map have significant trend.

Mann Kendall Statistic Map

You may also be interested in knowing the magnitude of the trend, or the slope of the trend over time in the present context. A non-parametric way of assessing that is with Sen's slope.

Sen's slope

Sen's slope is computed in a similar way to the Mann-Kendall statistic. However, instead of adding all the signs of the pairs, the slope is computed for all the pairs. Sen's slope is the median slope from all those pairs. In the following, slope is computed over days to avoid numerically tiny slopes (which might result from using epoch time instead).

  var 
  
 slope 
  
 = 
  
 function 
 ( 
 i 
 , 
  
 j 
 ) 
  
 { 
  
 // i and j are images 
  
 return 
  
 ee 
 . 
 Image 
 ( 
 j 
 ). 
 subtract 
 ( 
 i 
 ) 
  
 . 
 divide 
 ( 
 ee 
 . 
 Image 
 ( 
 j 
 ). 
 date 
 (). 
 difference 
 ( 
 ee 
 . 
 Image 
 ( 
 i 
 ). 
 date 
 (), 
  
 'days' 
 )) 
  
 . 
 rename 
 ( 
 'slope' 
 ) 
  
 . 
 float 
 (); 
 }; 
 var 
  
 slopes 
  
 = 
  
 ee 
 . 
 ImageCollection 
 ( 
 joined 
 . 
 map 
 ( 
 function 
 ( 
 current 
 ) 
  
 { 
  
 var 
  
 afterCollection 
  
 = 
  
 ee 
 . 
 ImageCollection 
 . 
 fromImages 
 ( 
 current 
 . 
 get 
 ( 
 'after' 
 )); 
  
 return 
  
 afterCollection 
 . 
 map 
 ( 
 function 
 ( 
 image 
 ) 
  
 { 
  
 return 
  
 ee 
 . 
 Image 
 ( 
 slope 
 ( 
 current 
 , 
  
 image 
 )); 
  
 }); 
 }). 
 flatten 
 ()); 
 var 
  
 sensSlope 
  
 = 
  
 slopes 
 . 
 reduce 
 ( 
 ee 
 . 
 Reducer 
 . 
 median 
 (), 
  
 2 
 ); 
  
 // Set parallelScale. 
 Map 
 . 
 addLayer 
 ( 
 sensSlope 
 , 
  
 { 
 palette 
 : 
  
 palette 
 , 
  
 min 
 : 
  
 - 
 1 
 , 
  
 max 
 : 
  
 1 
 }, 
  
 'sensSlope' 
 ); 
 

To get Sen's intercept (if you need it), compute all intercepts and take the median.

  var 
  
 epochDate 
  
 = 
  
 ee 
 . 
 Date 
 ( 
 '1970-01-01' 
 ); 
 var 
  
 sensIntercept 
  
 = 
  
 coll 
 . 
 map 
 ( 
 function 
 ( 
 image 
 ) 
  
 { 
  
 var 
  
 epochDays 
  
 = 
  
 image 
 . 
 date 
 (). 
 difference 
 ( 
 epochDate 
 , 
  
 'days' 
 ). 
 float 
 (); 
  
 return 
  
 image 
 . 
 subtract 
 ( 
 sensSlope 
 . 
 multiply 
 ( 
 epochDays 
 )). 
 float 
 (); 
 }). 
 reduce 
 ( 
 ee 
 . 
 Reducer 
 . 
 median 
 (), 
  
 2 
 ); 
 Map 
 . 
 addLayer 
 ( 
 sensIntercept 
 , 
  
 { 
 min 
 : 
  
 - 
 6600 
 , 
  
 max 
 : 
  
 20600 
 }, 
  
 'sensIntercept' 
 ); 
 

A map of Sen's slope is shown below. Note that the pattern is similar to the Mann-Kendall statistic, but not identical. Also, there is still the question of which pixels have significant trend, a question that will be answered shortly.

Sens Slope Map

The previous examples are only to demonstrate the computation. If you need Sen's slope and/or intercept, you can also use the Sen's slope reducer which is likely to be easier (less code) and more efficient, but computes the intercept as the y-value of the line that passes through the medians.

Variance of the Mann-Kendall statistic

Computing the variance of the Mann-Kendall statistic is complicated by the possible presence of ties in the data (i.e. sign(i, j) equals zero). Counting those ties can get a little dicey, requiring an array-based forward differencing. Note that you can comment .subtract(groupFactorSum) in the computation of kendallVariance if you don't care about ties and want to disregard that correction.

  // Values that are in a group (ties).  Set all else to zero. 
 var 
  
 groups 
  
 = 
  
 coll 
 . 
 map 
 ( 
 function 
 ( 
 i 
 ) 
  
 { 
  
 var 
  
 matches 
  
 = 
  
 coll 
 . 
 map 
 ( 
 function 
 ( 
 j 
 ) 
  
 { 
  
 return 
  
 i 
 . 
 eq 
 ( 
 j 
 ); 
  
 // i and j are images. 
  
 }). 
 sum 
 (); 
  
 return 
  
 i 
 . 
 multiply 
 ( 
 matches 
 . 
 gt 
 ( 
 1 
 )); 
 }); 
 // Compute tie group sizes in a sequence.  The first group is discarded. 
 var 
  
 group 
  
 = 
  
 function 
 ( 
 array 
 ) 
  
 { 
  
 var 
  
 length 
  
 = 
  
 array 
 . 
 arrayLength 
 ( 
 0 
 ); 
  
 // Array of indices.  These are 1-indexed. 
  
 var 
  
 indices 
  
 = 
  
 ee 
 . 
 Image 
 ([ 
 1 
 ]) 
  
 . 
 arrayRepeat 
 ( 
 0 
 , 
  
 length 
 ) 
  
 . 
 arrayAccum 
 ( 
 0 
 , 
  
 ee 
 . 
 Reducer 
 . 
 sum 
 ()) 
  
 . 
 toArray 
 ( 
 1 
 ); 
  
 var 
  
 sorted 
  
 = 
  
 array 
 . 
 arraySort 
 (); 
  
 var 
  
 left 
  
 = 
  
 sorted 
 . 
 arraySlice 
 ( 
 0 
 , 
  
 1 
 ); 
  
 var 
  
 right 
  
 = 
  
 sorted 
 . 
 arraySlice 
 ( 
 0 
 , 
  
 0 
 , 
  
 - 
 1 
 ); 
  
 // Indices of the end of runs. 
  
 var 
  
 mask 
  
 = 
  
 left 
 . 
 neq 
 ( 
 right 
 ) 
  
 // Always keep the last index, the end of the sequence. 
  
 . 
 arrayCat 
 ( 
 ee 
 . 
 Image 
 ( 
 ee 
 . 
 Array 
 ([[ 
 1 
 ]])), 
  
 0 
 ); 
  
 var 
  
 runIndices 
  
 = 
  
 indices 
 . 
 arrayMask 
 ( 
 mask 
 ); 
  
 // Subtract the indices to get run lengths. 
  
 var 
  
 groupSizes 
  
 = 
  
 runIndices 
 . 
 arraySlice 
 ( 
 0 
 , 
  
 1 
 ) 
  
 . 
 subtract 
 ( 
 runIndices 
 . 
 arraySlice 
 ( 
 0 
 , 
  
 0 
 , 
  
 - 
 1 
 )); 
  
 return 
  
 groupSizes 
 ; 
 }; 
 // See equation 2.6 in Sen (1968). 
 var 
  
 factors 
  
 = 
  
 function 
 ( 
 image 
 ) 
  
 { 
  
 return 
  
 image 
 . 
 expression 
 ( 
 'b() * (b() - 1) * (b() * 2 + 5)' 
 ); 
 }; 
 var 
  
 groupSizes 
  
 = 
  
 group 
 ( 
 groups 
 . 
 toArray 
 ()); 
 var 
  
 groupFactors 
  
 = 
  
 factors 
 ( 
 groupSizes 
 ); 
 var 
  
 groupFactorSum 
  
 = 
  
 groupFactors 
 . 
 arrayReduce 
 ( 
 'sum' 
 , 
  
 [ 
 0 
 ]) 
  
 . 
 arrayGet 
 ([ 
 0 
 , 
  
 0 
 ]); 
 var 
  
 count 
  
 = 
  
 joined 
 . 
 count 
 (); 
 var 
  
 kendallVariance 
  
 = 
  
 factors 
 ( 
 count 
 ) 
  
 . 
 subtract 
 ( 
 groupFactorSum 
 ) 
  
 . 
 divide 
 ( 
 18 
 ) 
  
 . 
 float 
 (); 
 Map 
 . 
 addLayer 
 ( 
 kendallVariance 
 , 
  
 { 
 min 
 : 
  
 1700 
 , 
  
 max 
 : 
  
 85000 
 }, 
  
 'kendallVariance' 
 ); 
 

Significance testing

The Mann-Kendall statistic is asymptotically normal for suitably large samples. Assume our samples are suitably large and uncorrelated. Under these assumptions, the true mean of the Mann-Kendall statistic is zero and the variance is as computed above. To compute a standard normal statistic ( z ), divide the statistic by its standard deviation. The P-value of the z-statistic (probability of observing such an extreme value) is 1 - P(|z| < Z) . For a two-sided test of whether any trend exists (positive or negative) at the 95% confidence level, compare the P-value to 0.975. (Alternatively, compare the z-statistic to Z* , where Z* is the inverse distribution function of 0.975).

The standard normal distribution function can be computed in Earth Engine from the error function, erf() . Both the distribution function and its inverse are shown below for reference. Here we use the distribution function to get 1 - P(|z| < Z) and compare it to 0.975.

  // Compute Z-statistics. 
 var 
  
 zero 
  
 = 
  
 kendall 
 . 
 multiply 
 ( 
 kendall 
 . 
 eq 
 ( 
 0 
 )); 
 var 
  
 pos 
  
 = 
  
 kendall 
 . 
 multiply 
 ( 
 kendall 
 . 
 gt 
 ( 
 0 
 )). 
 subtract 
 ( 
 1 
 ); 
 var 
  
 neg 
  
 = 
  
 kendall 
 . 
 multiply 
 ( 
 kendall 
 . 
 lt 
 ( 
 0 
 )). 
 add 
 ( 
 1 
 ); 
 var 
  
 z 
  
 = 
  
 zero 
  
 . 
 add 
 ( 
 pos 
 . 
 divide 
 ( 
 kendallVariance 
 . 
 sqrt 
 ())) 
  
 . 
 add 
 ( 
 neg 
 . 
 divide 
 ( 
 kendallVariance 
 . 
 sqrt 
 ())); 
 Map 
 . 
 addLayer 
 ( 
 z 
 , 
  
 { 
 min 
 : 
  
 - 
 2 
 , 
  
 max 
 : 
  
 2 
 }, 
  
 'z' 
 ); 
 // https://en.wikipedia.org/wiki/Error_function#Cumulative_distribution_function 
 function 
  
 eeCdf 
 ( 
 z 
 ) 
  
 { 
  
 return 
  
 ee 
 . 
 Image 
 ( 
 0.5 
 ) 
  
 . 
 multiply 
 ( 
 ee 
 . 
 Image 
 ( 
 1 
 ). 
 add 
 ( 
 ee 
 . 
 Image 
 ( 
 z 
 ). 
 divide 
 ( 
 ee 
 . 
 Image 
 ( 
 2 
 ). 
 sqrt 
 ()). 
 erf 
 ())); 
 } 
 function 
  
 invCdf 
 ( 
 p 
 ) 
  
 { 
  
 return 
  
 ee 
 . 
 Image 
 ( 
 2 
 ). 
 sqrt 
 () 
  
 . 
 multiply 
 ( 
 ee 
 . 
 Image 
 ( 
 p 
 ). 
 multiply 
 ( 
 2 
 ). 
 subtract 
 ( 
 1 
 ). 
 erfInv 
 ()); 
 } 
 // Compute P-values. 
 var 
  
 p 
  
 = 
  
 ee 
 . 
 Image 
 ( 
 1 
 ). 
 subtract 
 ( 
 eeCdf 
 ( 
 z 
 . 
 abs 
 ())); 
 Map 
 . 
 addLayer 
 ( 
 p 
 , 
  
 { 
 min 
 : 
  
 0 
 , 
  
 max 
 : 
  
 1 
 }, 
  
 'p' 
 ); 
 // Pixels that can have the null hypothesis (there is no trend) rejected. 
 // Specifically, if the true trend is zero, there would be less than 5% 
 // chance of randomly obtaining the observed result (that there is a trend). 
 Map 
 . 
 addLayer 
 ( 
 p 
 . 
 lte 
 ( 
 0.025 
 ), 
  
 { 
 min 
 : 
  
 0 
 , 
  
 max 
 : 
  
 1 
 }, 
  
 'significant trends' 
 ); 
 

The following shows a map of the "significant" pixels (in white), or the pixels that pass the p.lte(0.025) test. If our assumptions are correct and you are satisfied with the computation, you may wish to treat these pixels differently, by using p.lte(0.025) as a mask. Note that the pixel with the time series shown above (red in the map below) does NOT have a significant trend.

Statistically Significant Pixels

Some call this process significance testing. Pixels that are "significant" satisfy the condition that p.lte(0.025) and are assumed to have a real trend. Other pixels are assumed to not have a trend and are removed from further analysis. Some consider this process cultish ( Ziliak and McCloskey 2009 ). You decide!

References

Create a Mobile Website
View Site in Mobile | Classic
Share by: