From db8fcc7682f6e4d2c869dd886806ccd31f5f21c7 Mon Sep 17 00:00:00 2001 From: rhugonne Date: Sun, 27 Jun 2021 12:18:18 +0200 Subject: [PATCH 001/113] incremental commit for spatialstats documentation --- docs/source/code/spatialstats.py | 40 ++++++++++++++++++ .../source/code/spatialstats_empirical_vgm.py | 38 +++++++++++++++++ docs/source/code/spatialstats_model_vgm.py | 42 +++++++++++++++++++ .../{spatial_stats.rst => robuststats.rst} | 37 ++-------------- docs/source/spatialstats.rst | 39 +++++++++++++++++ tests/test_spstats.py | 4 +- xdem/spstats.py | 27 +++++++----- 7 files changed, 182 insertions(+), 45 deletions(-) create mode 100644 docs/source/code/spatialstats.py create mode 100644 docs/source/code/spatialstats_empirical_vgm.py create mode 100644 docs/source/code/spatialstats_model_vgm.py rename docs/source/{spatial_stats.rst => robuststats.rst} (63%) create mode 100644 docs/source/spatialstats.rst diff --git a/docs/source/code/spatialstats.py b/docs/source/code/spatialstats.py new file mode 100644 index 00000000..2d0d3c7c --- /dev/null +++ b/docs/source/code/spatialstats.py @@ -0,0 +1,40 @@ +import xdem +import geoutils as gu +import numpy as np + +# load diff and mask +xdem.examples.download_longyearbyen_examples(overwrite=False) + +reference_raster = gu.georaster.Raster(xdem.examples.FILEPATHS["longyearbyen_ref_dem"]) +to_be_aligned_raster = gu.georaster.Raster(xdem.examples.FILEPATHS["longyearbyen_tba_dem"]) +glacier_mask = gu.geovector.Vector(xdem.examples.FILEPATHS["longyearbyen_glacier_outlines"]) +inlier_mask = ~glacier_mask.create_mask(reference_raster) + +nuth_kaab = xdem.coreg.NuthKaab() +nuth_kaab.fit(reference_raster.data, to_be_aligned_raster.data, + inlier_mask=inlier_mask, transform=reference_raster.transform) +aligned_raster = nuth_kaab.apply(to_be_aligned_raster.data, transform=reference_raster.transform) + +ddem = gu.Raster.from_array((reference_raster.data - aligned_raster), + transform=reference_raster.transform, crs=reference_raster.crs) +mask = glacier_mask.create_mask(ddem) + +# ddem is a difference of DEMs +x, y = ddem.coords(offset='center') +coords = np.dstack((x.flatten(), y.flatten())).squeeze() + +# Sample empirical variogram +df = xdem.spstats.sample_multirange_empirical_variogram(dh=ddem.data, nsamp=1000, nrun=20, nproc=10, maxlag=10000) + +# Fit single-range spherical model +fun, coefs = xdem.spstats.fit_model_sum_vgm(['Sph'], df) + +# Fit sum of triple-range spherical model +fun2, coefs2 = xdem.spstats.fit_model_sum_vgm(['Sph', 'Sph', 'Sph'], emp_vgm_df=df) + +# Calculate the area-averaged uncertainty with these models +list_vgm = [(coefs[2*i],'Sph',coefs[2*i+1]) for i in range(int(len(coefs)/2))] +neff = xdem.spstats.neff_circ(1,list_vgm) + + + diff --git a/docs/source/code/spatialstats_empirical_vgm.py b/docs/source/code/spatialstats_empirical_vgm.py new file mode 100644 index 00000000..1d210c1f --- /dev/null +++ b/docs/source/code/spatialstats_empirical_vgm.py @@ -0,0 +1,38 @@ +import matplotlib.pyplot as plt + +import geoutils as gu +import xdem +import numpy as np + +# load diff and mask +xdem.examples.download_longyearbyen_examples(overwrite=False) + +reference_raster = gu.georaster.Raster(xdem.examples.FILEPATHS["longyearbyen_ref_dem"]) +to_be_aligned_raster = gu.georaster.Raster(xdem.examples.FILEPATHS["longyearbyen_tba_dem"]) +glacier_mask = gu.geovector.Vector(xdem.examples.FILEPATHS["longyearbyen_glacier_outlines"]) +inlier_mask = ~glacier_mask.create_mask(reference_raster) + +nuth_kaab = xdem.coreg.NuthKaab() +nuth_kaab.fit(reference_raster.data, to_be_aligned_raster.data, + inlier_mask=inlier_mask, transform=reference_raster.transform) +aligned_raster = nuth_kaab.apply(to_be_aligned_raster.data, transform=reference_raster.transform) + +ddem = gu.Raster.from_array((reference_raster.data - aligned_raster), + transform=reference_raster.transform, crs=reference_raster.crs) +mask = glacier_mask.create_mask(ddem) + +# extract coordinates +x, y = ddem.coords(offset='center') +coords = np.dstack((x.flatten(), y.flatten())).squeeze() + +# ensure the figures are reproducible +np.random.seed(42) + +# sample empirical variogram +df = xdem.spstats.sample_multirange_empirical_variogram(dh=ddem.data, nsamp=1000, nrun=20, maxlag=10000) + +# plot empirical variogram +xdem.spstats.plot_vgm(df) +plt.show() + + diff --git a/docs/source/code/spatialstats_model_vgm.py b/docs/source/code/spatialstats_model_vgm.py new file mode 100644 index 00000000..ef8651d2 --- /dev/null +++ b/docs/source/code/spatialstats_model_vgm.py @@ -0,0 +1,42 @@ +import matplotlib.pyplot as plt + +import geoutils as gu +import xdem +import numpy as np + +# load diff and mask +xdem.examples.download_longyearbyen_examples(overwrite=False) + +reference_raster = gu.georaster.Raster(xdem.examples.FILEPATHS["longyearbyen_ref_dem"]) +to_be_aligned_raster = gu.georaster.Raster(xdem.examples.FILEPATHS["longyearbyen_tba_dem"]) +glacier_mask = gu.geovector.Vector(xdem.examples.FILEPATHS["longyearbyen_glacier_outlines"]) +inlier_mask = ~glacier_mask.create_mask(reference_raster) + +nuth_kaab = xdem.coreg.NuthKaab() +nuth_kaab.fit(reference_raster.data, to_be_aligned_raster.data, + inlier_mask=inlier_mask, transform=reference_raster.transform) +aligned_raster = nuth_kaab.apply(to_be_aligned_raster.data, transform=reference_raster.transform) + +ddem = gu.Raster.from_array((reference_raster.data - aligned_raster), + transform=reference_raster.transform, crs=reference_raster.crs) +mask = glacier_mask.create_mask(ddem) + +# extract coordinates +x, y = ddem.coords(offset='center') +coords = np.dstack((x.flatten(), y.flatten())).squeeze() + +# ensure the figures are reproducible +np.random.seed(42) + +# sample empirical variogram +df = xdem.spstats.sample_multirange_empirical_variogram(dh=ddem.data, nsamp=1000, nrun=20, maxlag=10000) + +fun, _ = xdem.spstats.fit_model_sum_vgm(['Sph'], df) +fun2, _ = xdem.spstats.fit_model_sum_vgm(['Sph', 'Sph', 'Sph'], emp_vgm_df=df) +xdem.spstats.plot_vgm(df, list_fit_fun=[fun,fun2],list_fit_fun_label=['Spherical model','Sum of three spherical models']) + +# plot empirical variogram +xdem.spstats.plot_vgm(df) +plt.show() + + diff --git a/docs/source/spatial_stats.rst b/docs/source/robuststats.rst similarity index 63% rename from docs/source/spatial_stats.rst rename to docs/source/robuststats.rst index 588b10b9..309b53b9 100644 --- a/docs/source/spatial_stats.rst +++ b/docs/source/robuststats.rst @@ -1,10 +1,9 @@ -Spatial statistics +Robust statistics ================== -How does one calculate the error between two DEMs? -This question is the basis of numerous academic papers, but their conclusions are often hard to grasp as the mathematics behind them can be quite daunting. -In addition, the lack of a simple implementation in a modern programming language makes these methods obscure and used only by those who can program it themselves. -One of the goals of ``xdem`` is to simplify state-of-the-art statistical measures, to allow accurate DEM comparisons for everyone, regardless of one's statistical talent. +Digital Elevation Models often contain outliers that hamper further analysis. +In order to deal with outliers, ``xdem`` integrates statistical measures robust to outliers to be used for estimation of the +mean or dispersion of a sample, or more complex function fitting. .. contents:: Contents :local: @@ -69,31 +68,3 @@ TODO: Add a rationale for this approach. .. code-block:: python nmad = xdem.spatial_tools.nmad(ddem.data) - -Standard error -************** -The standard error (SE) is a measure of the total integrated uncertainty over a multitude of point values. -For dDEMs, the SE is good for quantifying the effect of stochastic (random) error in mean elevation and volume change calculations. - -.. math:: - - SE_{dh} = \frac{STD_{dh}}{\sqrt{N}}, - -where :math:`SE_{dh}` is the standard error of elevation change, :math:`STD_{dh}` is the standard deviation of the samples in the area of interest, and :math:`N` is the number of **independent** observations. - -Note that correct use of the SE assumes that the standard deviation represents completely stochastic (independent / random) error. -The SE is therefore useful once all systematic (non-random) errors have been accounted for, e.g. using one or multiple :ref:`coregistration` approaches. - -.. code-block:: python - - se = ddem.data.std() / np.sqrt(ddem.data.flatten().shape[0]) - -Periglacial error -^^^^^^^^^^^^^^^^^ -TODO: Add this section - - -Variograms -^^^^^^^^^^ - -TODO: Add this section diff --git a/docs/source/spatialstats.rst b/docs/source/spatialstats.rst new file mode 100644 index 00000000..3b997fef --- /dev/null +++ b/docs/source/spatialstats.rst @@ -0,0 +1,39 @@ +Spatial statistics +================== + +How does one calculate the error between two DEMs? +This question is the basis of numerous academic papers, but their conclusions are often hard to grasp as the mathematics behind them can be quite daunting. +In addition, the lack of a simple implementation in a modern programming language makes these methods obscure and used only by those who can program it themselves. +One of the goals of ``xdem`` is to simplify state-of-the-art statistical measures, to allow accurate DEM comparisons for everyone, regardless of one's statistical talent. + +.. contents:: Contents + :local: + + +Standard error +************** +The standard error (SE) is a measure of the total integrated uncertainty over a multitude of point values. +For dDEMs, the SE is good for quantifying the effect of stochastic (random) error in mean elevation and volume change calculations. + +.. math:: + + SE_{dh} = \frac{STD_{dh}}{\sqrt{N}}, + +where :math:`SE_{dh}` is the standard error of elevation change, :math:`STD_{dh}` is the standard deviation of the samples in the area of interest, and :math:`N` is the number of **independent** observations. + +Note that correct use of the SE assumes that the standard deviation represents completely stochastic (independent / random) error. +The SE is therefore useful once all systematic (non-random) errors have been accounted for, e.g. using one or multiple :ref:`coregistration` approaches. + +.. code-block:: python + + se = ddem.data.std() / np.sqrt(ddem.data.flatten().shape[0]) + +Periglacial error +^^^^^^^^^^^^^^^^^ +TODO: Add this section + + +Variograms +^^^^^^^^^^ + +TODO: Add this section diff --git a/tests/test_spstats.py b/tests/test_spstats.py index 01a33e33..056f2288 100644 --- a/tests/test_spstats.py +++ b/tests/test_spstats.py @@ -96,13 +96,13 @@ def test_empirical_fit_variogram_running(self): # single model fit fun, _ = xdem.spstats.fit_model_sum_vgm(['Sph'], df_sig) if PLOT: - xdem.spstats.plot_vgm(df_sig, fit_fun=fun) + xdem.spstats.plot_vgm(df_sig, list_fit_fun=[fun]) try: # triple model fit fun2, _ = xdem.spstats.fit_model_sum_vgm(['Sph', 'Sph', 'Sph'], emp_vgm_df=df_sig) if PLOT: - xdem.spstats.plot_vgm(df_sig, fit_fun=fun2) + xdem.spstats.plot_vgm(df_sig, list_fit_fun=[fun2]) except RuntimeError as exception: if "The maximum number of function evaluations is exceeded." not in str(exception): raise exception diff --git a/xdem/spstats.py b/xdem/spstats.py index 018e1945..4c88c068 100644 --- a/xdem/spstats.py +++ b/xdem/spstats.py @@ -9,7 +9,7 @@ import random import warnings from functools import partial -from typing import Callable, Union +from typing import Callable, Union, Optional import matplotlib.pyplot as plt import numpy as np @@ -236,7 +236,7 @@ def fit_model_sum_vgm(list_model: list[str], emp_vgm_df: pd.DataFrame) -> tuple[ :param list_model: list of variogram models to sum for the fit: from short-range to long-ranges :param emp_vgm_df: empirical variogram - :return: modelled variogram + :return: modelled variogram function, coefficients """ # TODO: expand to other models than spherical, exponential, gaussian (more than 2 arguments) def vgm_sum(h, *args): @@ -310,7 +310,7 @@ def vgm_sum_fit(h): def exact_neff_sphsum_circular(area: float, crange1: float, psill1: float, crange2: float, psill2: float) -> float: """ - Number of effective samples derived from exact integration of sum of spherical variogram models over a circular area. + Number of effective samples derived from exact integration of sum of 2 spherical variogram models over a circular area. The number of effective samples serves to convert between standard deviation/partial sills and standard error over the area. If SE is the standard error, SD the standard deviation and N_eff the number of effective samples, we have: @@ -349,7 +349,7 @@ def exact_neff_sphsum_circular(area: float, crange1: float, psill1: float, crang return (psill1 + psill2)/std_err**2 -def neff_circ(area: float, list_vgm: list[Union[float, str, float]]) -> float: +def neff_circ(area: float, list_vgm: list[tuple[float, str, float]]) -> float: """ Number of effective samples derived from numerical integration for any sum of variogram models a circular area (generalization of Rolstad et al. (2009): http://dx.doi.org/10.3189/002214309789470950) @@ -357,7 +357,7 @@ def neff_circ(area: float, list_vgm: list[Union[float, str, float]]) -> float: over the area: SE = SD / sqrt(N_eff) if SE is the standard error, SD the standard deviation. :param area: area - :param list_vgm: variogram functions to sum + :param list_vgm: variogram functions to sum (range, model name, partial sill) :returns: number of effective samples """ @@ -723,7 +723,7 @@ def create_circular_mask(h, w, center=None, radius=None): return df -def plot_vgm(df: pd.DataFrame, fit_fun: Callable = None): +def plot_vgm(df: pd.DataFrame, list_fit_fun: Optional[list[Callable]] = None, list_fit_fun_label: Optional[list[str]] = None): fig, ax = plt.subplots(1) if np.all(np.isnan(df.exp_sigma)): @@ -731,11 +731,18 @@ def plot_vgm(df: pd.DataFrame, fit_fun: Callable = None): else: ax.errorbar(df.bins, df.exp, yerr=df.exp_sigma, label='Empirical variogram (1-sigma s.d)') - if fit_fun is not None: - x = np.linspace(0, np.max(df.bins), 10000) - y = fit_fun(x) + if list_fit_fun is not None: + for i, fit_fun in enumerate(list_fit_fun): + x = np.linspace(0, np.max(df.bins), 10000) + y = fit_fun(x) - ax.plot(x, y, linestyle='dashed', color='black', label='Model fit', zorder=30) + if list_fit_fun_label is not None: + ax.plot(x, y, linestyle='dashed', label=list_fit_fun_label[i], zorder=30) + else: + ax.plot(x, y, linestyle='dashed', color='black', zorder=30) + + if list_fit_fun_label is None: + ax.plot([],[],linestyle='dashed',color='black',label='Model fit') ax.set_xlabel('Lag (m)') ax.set_ylabel(r'Variance [$\mu$ $\pm \sigma$]') From 5415f37fd373d5ae528ff04cbe00411decded57b Mon Sep 17 00:00:00 2001 From: rhugonne Date: Sun, 27 Jun 2021 16:27:24 +0200 Subject: [PATCH 002/113] incremental commit for spatialstats documentation --- docs/source/spatialstats.rst | 82 +++++++++++++++++++++++++++--------- 1 file changed, 62 insertions(+), 20 deletions(-) diff --git a/docs/source/spatialstats.rst b/docs/source/spatialstats.rst index 3b997fef..9609ce4d 100644 --- a/docs/source/spatialstats.rst +++ b/docs/source/spatialstats.rst @@ -1,39 +1,81 @@ Spatial statistics ================== -How does one calculate the error between two DEMs? -This question is the basis of numerous academic papers, but their conclusions are often hard to grasp as the mathematics behind them can be quite daunting. -In addition, the lack of a simple implementation in a modern programming language makes these methods obscure and used only by those who can program it themselves. -One of the goals of ``xdem`` is to simplify state-of-the-art statistical measures, to allow accurate DEM comparisons for everyone, regardless of one's statistical talent. +Spatial statistics, also referred to as geostatistics, are essential for the analysis of observations distributed in space. +To analyze DEMs, ``xdem`` integrates spatial statistics tools specific to DEMs based on recent literature, and with routines partly relying on `scikit-gstat `_. + +The spatial statistics tools can be used to: + - account for non-stationarities of elevation measurement errors (e.g., varying accuracy of DEMs with terrain slope), + - quantify the spatial correlation in DEMs (e.g., native spatial resolution, instrument noise), + - estimate robust errors for observations integrated in space (e.g., average or sum of samples), + - propagate errors between spatial ensembles at different scales (e.g., sum of glacier volume changes). + +More details below. .. contents:: Contents :local: -Standard error -************** -The standard error (SE) is a measure of the total integrated uncertainty over a multitude of point values. -For dDEMs, the SE is good for quantifying the effect of stochastic (random) error in mean elevation and volume change calculations. +Introduction: why is it complex to assess DEM accuracy? +******************************************************* + +Digital Elevation Models are a numerical representations of topographies. They are generated from different instruments (radiometer, radar, lidar), acquired in different conditions (ground, airborne, satellite), and using different post-processing techniques (stereophotogrammetry, interferometry, etc.). + +While some complexities are specific to certain instruments, all DEMs generally have: + - an arbitrary Ground Sampling Distance (GSD) that does not necessarily represent their underlying spatial resolution, + - an absolute georeferenced positioning subject to shifts, tilts or other deformations due to inherent instrument errors, noise, and associated post-processing schemes, + - a large number of outliers that can originate from various sources (e.g., photogrammetric blunders, clouds). + +Absolute or Relative? +^^^^^^^^^^^^^^^^^^^^^ + +Pixel-wise elevation measurement errors +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + + + +Spatially-integrated elevation measurement error +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The standard error (SE) of a statistic is the standard deviation of the distribution of this statistic. +For spatially distributed samples, the standard error of the mean (SEM) is of great interest as it allows quantification of the error of a mean (or sum) of samples in space. + +The standard error :math:`\sigma_{\overline{dh}}` of the mean :math:`\overline{dh}` of elevation changes samples :math:`dh` is typically derived as: .. math:: - SE_{dh} = \frac{STD_{dh}}{\sqrt{N}}, + \sigma_{\overline{dh}} = \frac{\sigma_{dh}}{\sqrt{N}}, + +where :math:`\sigma_{dh}` is the dispersion of the samples, and :math:`N` is the number of **independent** observations. + +However, several issues arise to estimate the standard error of a mean of elevation observations samples: + 1. The dispersion :math:`\sigma_{dh}` cannot be estimated directly on changing terrain that we are usually interested in measuring (e.g., glacier, snow, forest). + 2. The dispersion :math:`\sigma_{dh}` typically shows important non-stationarities (e.g., an error 10 times as large on steep slopes than flat slopes). + 3. The number of samples :math:`N` is generally not independent in space, as the Ground Sampling Distance of the DEM does not necessarily correspond to its effective resolution. + +Note that the SE represents completely stochastic (random) errors, and is therefore not accounting for possible remaining systematic errors have been accounted for, e.g. using one or multiple :ref:`coregistration` approaches. + + +Relative spatial accuracy of a DEM +********************************** + -where :math:`SE_{dh}` is the standard error of elevation change, :math:`STD_{dh}` is the standard deviation of the samples in the area of interest, and :math:`N` is the number of **independent** observations. +Non-stationarity in elevation measurement errors +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +TODO: Add this section based on Hugonnet et al. (in prep) -Note that correct use of the SE assumes that the standard deviation represents completely stochastic (independent / random) error. -The SE is therefore useful once all systematic (non-random) errors have been accounted for, e.g. using one or multiple :ref:`coregistration` approaches. -.. code-block:: python +Multi-range spatial correlations +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - se = ddem.data.std() / np.sqrt(ddem.data.flatten().shape[0]) +TODO: Add this section based Rolstad et al. (2009), Dehecq et al. (2020), Hugonnet et al. (in prep) -Periglacial error -^^^^^^^^^^^^^^^^^ -TODO: Add this section +Spatially integrated measurement errors +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +TODO: Add this section based on Rolstad et al. (2009), Hugonnet et al. (in prep) -Variograms -^^^^^^^^^^ +Propagation of correlated errors +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -TODO: Add this section +TODO: Add this section based on Krige's relation (Webster & Oliver, 2007 From d875f1550acdce674d3756a0aa93d9857d034ae7 Mon Sep 17 00:00:00 2001 From: rhugonne Date: Sun, 27 Jun 2021 19:50:53 +0200 Subject: [PATCH 003/113] incremental commit on spatialstats documentation --- docs/source/spatialstats.rst | 33 +++++++++++++++++++++++---------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/docs/source/spatialstats.rst b/docs/source/spatialstats.rst index 9609ce4d..11084e37 100644 --- a/docs/source/spatialstats.rst +++ b/docs/source/spatialstats.rst @@ -5,7 +5,7 @@ Spatial statistics, also referred to as geostatistics, are essential for the ana To analyze DEMs, ``xdem`` integrates spatial statistics tools specific to DEMs based on recent literature, and with routines partly relying on `scikit-gstat `_. The spatial statistics tools can be used to: - - account for non-stationarities of elevation measurement errors (e.g., varying accuracy of DEMs with terrain slope), + - account for non-stationarities of elevation measurement errors (e.g., varying precision of DEMs with terrain slope), - quantify the spatial correlation in DEMs (e.g., native spatial resolution, instrument noise), - estimate robust errors for observations integrated in space (e.g., average or sum of samples), - propagate errors between spatial ensembles at different scales (e.g., sum of glacier volume changes). @@ -16,18 +16,31 @@ More details below. :local: -Introduction: why is it complex to assess DEM accuracy? -******************************************************* +Introduction: why is it complex to assess DEM accuracy and precision? +********************************************************************* -Digital Elevation Models are a numerical representations of topographies. They are generated from different instruments (radiometer, radar, lidar), acquired in different conditions (ground, airborne, satellite), and using different post-processing techniques (stereophotogrammetry, interferometry, etc.). +Digital Elevation Models are a numerical representations of elevation. They are generated from different instruments (radiometer, radar, lidar), acquired in different conditions (ground, airborne, satellite), and using different post-processing techniques (stereophotogrammetry, interferometry, etc.). While some complexities are specific to certain instruments, all DEMs generally have: - - an arbitrary Ground Sampling Distance (GSD) that does not necessarily represent their underlying spatial resolution, - - an absolute georeferenced positioning subject to shifts, tilts or other deformations due to inherent instrument errors, noise, and associated post-processing schemes, - - a large number of outliers that can originate from various sources (e.g., photogrammetric blunders, clouds). + - an **arbitrary Ground Sampling Distance (GSD)** that does not necessarily represent their underlying spatial resolution, + - an **georeferenced positioning subject to shifts, tilts or other deformations** due to inherent instrument errors, noise, or associated post-processing schemes, + - a **large number of outliers** that can originate from various sources (e.g., photogrammetric blunders, clouds). -Absolute or Relative? -^^^^^^^^^^^^^^^^^^^^^ +DEM accuracy or DEM precision +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Both DEM accuracy and precision can be of interest when analyzing DEMs: + - the **accuracy** (systematic error) of a DEM describes how close a DEM is to the true location of measured elevations on the Earth's surface, + - the **precision** (random error) of a DEM describes the typical spread of its error in measurement, independently of a possible bias from the true positioning. + +Absolute or relative accuracy +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The measure of accuracy can be further split into two: + - the **absolute accuracy** of a DEM is the average shift to the true positioning. Studies interested in analyzing features of a single DEM might give great importance to this potential bias, which can be easily removed through a DEM co-registration with accurate, georeferenced point elevation data such as ICESat and ICESat-2 (:ref:`coregistration`). + - the **relative accuracy** of a DEM is the potential shifts, tilts, and deformations in relation to other elevation data, not necessarily with true absolute referencing. Studies interested in comparing several DEMs in between them can focus only on this accuracy relative to the DEMs, by performed co-registration in between the DEMs and correcting for possible biases (:ref:`coregistration`, TODO: ref bias corrections). + +As the **absolute accuracy** can be easily corrected a posteriori with an reference elevation dataset, we here only focus on **relative accuracy**, i.e. the biases between to DEMs co-registered relative one to another. Pixel-wise elevation measurement errors ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -51,7 +64,7 @@ where :math:`\sigma_{dh}` is the dispersion of the samples, and :math:`N` is the However, several issues arise to estimate the standard error of a mean of elevation observations samples: 1. The dispersion :math:`\sigma_{dh}` cannot be estimated directly on changing terrain that we are usually interested in measuring (e.g., glacier, snow, forest). 2. The dispersion :math:`\sigma_{dh}` typically shows important non-stationarities (e.g., an error 10 times as large on steep slopes than flat slopes). - 3. The number of samples :math:`N` is generally not independent in space, as the Ground Sampling Distance of the DEM does not necessarily correspond to its effective resolution. + 3. The number of samples :math:`N` is generally not equal to the number of sampled DEM pixels, as those are not independent in space and the Ground Sampling Distance of the DEM does not necessarily correspond to its effective resolution. Note that the SE represents completely stochastic (random) errors, and is therefore not accounting for possible remaining systematic errors have been accounted for, e.g. using one or multiple :ref:`coregistration` approaches. From d7df5c0589fc502bf3e2564d648bd60eb07caebd Mon Sep 17 00:00:00 2001 From: rhugonne Date: Mon, 28 Jun 2021 10:54:16 +0200 Subject: [PATCH 004/113] incremental commit for spatialstats documentation --- docs/source/index.rst | 5 ++-- docs/source/intro.rst | 50 ++++++++++++++++++++++++++++++++++++ docs/source/spatialstats.rst | 27 ++----------------- 3 files changed, 55 insertions(+), 27 deletions(-) create mode 100644 docs/source/intro.rst diff --git a/docs/source/index.rst b/docs/source/index.rst index 1e4c576a..fd3530c6 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -5,7 +5,7 @@ Welcome to xdem's documentation! ================================ -xdem aims to make Digital Elevation Model (DEM) comparisons easy. +xdem aims to make Digital Elevation Model (DEM) analysis easy. Coregistration, subtraction (and volume measurements), and error statistics should be available to anyone with the correct input data. @@ -28,9 +28,10 @@ Simple usage :caption: Contents: tutorials + intro coregistration comparison - spatial_stats + spatialstats api/xdem.rst Indices and tables diff --git a/docs/source/intro.rst b/docs/source/intro.rst new file mode 100644 index 00000000..c768e900 --- /dev/null +++ b/docs/source/intro.rst @@ -0,0 +1,50 @@ +Introduction: why is it complex to assess DEM accuracy and precision? +===================================================================== + +Digital Elevation Models are a numerical representations of elevation, essentially maps of topography. They are generated from different instruments (e.g., radiometer, radar, lidar), acquired in different conditions (e.g., ground, airborne, satellite), and using different post-processing techniques (e.g., stereophotogrammetry, interferometry). + +While some complexities are specific to certain instruments and methods, all DEMs generally have: + - an **arbitrary Ground Sampling Distance (GSD)** that does not necessarily represent their underlying spatial resolution, + - an **georeferenced positioning subject to shifts, tilts or other deformations** due to inherent instrument errors, noise, or associated post-processing schemes, + - a **large number of outliers** that can originate from various sources (e.g., photogrammetric blunders, clouds). + +These factors lead to difficulties in assessing the accuracy and precision of DEMs, which is necessary for further analysis. + +Accuracy and precision +********************** + +Both accuracy and precision are important when analyzing DEMs: + - the **accuracy** (systematic error) of a DEM describes how close a DEM is to the true location of measured elevations on the Earth's surface, + - the **precision** (random error) of a DEM describes the typical spread of its error in measurement, independently of a possible bias from the true positioning. + +Absolute or relative accuracy +***************************** + +The measure of accuracy can be further split into two: + - the **absolute accuracy** of a DEM is the average shift to the true positioning. Studies interested in analyzing features of a single DEM in relation to other georeferenced data might give great importance to this potential bias. + - the **relative accuracy** of a DEM is the potential shifts, tilts, and deformations in relation to other elevation data that does not necessarily matches a given referencing. Studies interested in comparing DEMs between themselves might be only interested in this accuracy. + + +Dealing with DEM absolute accuracy +********************************** + +Shifts due to poor absolute accuracy are common in elevation datasets, and can be easily corrected by performing a DEM co-registration to accurate, georeferenced point elevation data such as ICESat and ICESat-2. + +For more details, see :ref:`coregistration` with point data. + +Dealing with DEM relative accuracy +********************************** + +As the **absolute accuracy** can be corrected a posteriori using reference elevation datasets, many analyses only focus on **relative accuracy**, i.e. the remaining biases between several DEMs co-registered relative one to another. +By harnessing the denser, nearly continuous sampling of raster DEMs (in opposition to the sparser sampling of higher-accuracy point elevation data), one can identify and correct other types of biases: + - Terrain-related biases that can originate from the difference of resolution of DEMs, or instrument processing deformations. + - Directional biases that can be linked to instrument noise, such as along-track oscillations observed in many widepsread DEM products (SRTM, ASTER, SPOT, Pléiades, etc). + +Those biases can be tackled by iteratively combining co-registration and bias-correction methods (:ref:`coregistration`, TODO: ref bias corrections). + +Dealing with DEM precision +************************** + +While dealing with **accuracy** is quite straightforward, as it consists of minimizing the difference corresponding to possible biases between several datasets, assessing the **precision** of DEMs can be much more complex. +The measurement error of a DEM is usually described by a single metric: + diff --git a/docs/source/spatialstats.rst b/docs/source/spatialstats.rst index 11084e37..3b1a6328 100644 --- a/docs/source/spatialstats.rst +++ b/docs/source/spatialstats.rst @@ -16,31 +16,8 @@ More details below. :local: -Introduction: why is it complex to assess DEM accuracy and precision? -********************************************************************* - -Digital Elevation Models are a numerical representations of elevation. They are generated from different instruments (radiometer, radar, lidar), acquired in different conditions (ground, airborne, satellite), and using different post-processing techniques (stereophotogrammetry, interferometry, etc.). - -While some complexities are specific to certain instruments, all DEMs generally have: - - an **arbitrary Ground Sampling Distance (GSD)** that does not necessarily represent their underlying spatial resolution, - - an **georeferenced positioning subject to shifts, tilts or other deformations** due to inherent instrument errors, noise, or associated post-processing schemes, - - a **large number of outliers** that can originate from various sources (e.g., photogrammetric blunders, clouds). - -DEM accuracy or DEM precision -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -Both DEM accuracy and precision can be of interest when analyzing DEMs: - - the **accuracy** (systematic error) of a DEM describes how close a DEM is to the true location of measured elevations on the Earth's surface, - - the **precision** (random error) of a DEM describes the typical spread of its error in measurement, independently of a possible bias from the true positioning. - -Absolute or relative accuracy -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -The measure of accuracy can be further split into two: - - the **absolute accuracy** of a DEM is the average shift to the true positioning. Studies interested in analyzing features of a single DEM might give great importance to this potential bias, which can be easily removed through a DEM co-registration with accurate, georeferenced point elevation data such as ICESat and ICESat-2 (:ref:`coregistration`). - - the **relative accuracy** of a DEM is the potential shifts, tilts, and deformations in relation to other elevation data, not necessarily with true absolute referencing. Studies interested in comparing several DEMs in between them can focus only on this accuracy relative to the DEMs, by performed co-registration in between the DEMs and correcting for possible biases (:ref:`coregistration`, TODO: ref bias corrections). - -As the **absolute accuracy** can be easily corrected a posteriori with an reference elevation dataset, we here only focus on **relative accuracy**, i.e. the biases between to DEMs co-registered relative one to another. +Metrics for DEM precision +************************* Pixel-wise elevation measurement errors ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ From 0dc2d5e7def1aae0d24196d3c86afa28840330fd Mon Sep 17 00:00:00 2001 From: rhugonne Date: Mon, 28 Jun 2021 12:16:25 +0200 Subject: [PATCH 005/113] incremental commit for spatialstats documentation --- docs/source/intro.rst | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/docs/source/intro.rst b/docs/source/intro.rst index c768e900..c2b353b4 100644 --- a/docs/source/intro.rst +++ b/docs/source/intro.rst @@ -1,14 +1,14 @@ Introduction: why is it complex to assess DEM accuracy and precision? ===================================================================== -Digital Elevation Models are a numerical representations of elevation, essentially maps of topography. They are generated from different instruments (e.g., radiometer, radar, lidar), acquired in different conditions (e.g., ground, airborne, satellite), and using different post-processing techniques (e.g., stereophotogrammetry, interferometry). +Digital Elevation Models are numerical representations of elevation. They are generated from different instruments (e.g., radiometer, radar, lidar), acquired in different conditions (e.g., ground, airborne, satellite), and using different post-processing techniques (e.g., stereophotogrammetry, interferometry). While some complexities are specific to certain instruments and methods, all DEMs generally have: - an **arbitrary Ground Sampling Distance (GSD)** that does not necessarily represent their underlying spatial resolution, - an **georeferenced positioning subject to shifts, tilts or other deformations** due to inherent instrument errors, noise, or associated post-processing schemes, - a **large number of outliers** that can originate from various sources (e.g., photogrammetric blunders, clouds). -These factors lead to difficulties in assessing the accuracy and precision of DEMs, which is necessary for further analysis. +These factors lead to difficulties in assessing the accuracy and precision of DEMs, necessary to perform further analysis. Accuracy and precision ********************** @@ -45,6 +45,12 @@ Those biases can be tackled by iteratively combining co-registration and bias-co Dealing with DEM precision ************************** -While dealing with **accuracy** is quite straightforward, as it consists of minimizing the difference corresponding to possible biases between several datasets, assessing the **precision** of DEMs can be much more complex. -The measurement error of a DEM is usually described by a single metric: +While dealing with **accuracy** is quite straightforward as it consists of minimizing the differences (biases) between several datasets, assessing the **precision** of DEMs can be much more complex. +Measurement errors of a DEM cannot be quantified by a simple difference and require statistical inference. + +The **precision** of DEMs has generally been reported by a single metric, for example: :math:`\pm` 2 m, but recent studies have shown the limitations of simplified metrics and provide more statistically advanced methods. +However, the lack of a simple implementation in a modern programming language makes these methods hard to reproduce and validate. +One of the goals of ``xdem`` is to simplify state-of-the-art statistical measures, to allow accurate DEM uncertainty estimation for everyone, regardless of one's statistical talent. + +The tools for quantifying DEM precision are described in :ref:`spatialstats`. From 0cfdd1e2c44abd314c7f77001744629307cb917d Mon Sep 17 00:00:00 2001 From: rhugonne Date: Mon, 28 Jun 2021 12:38:15 +0200 Subject: [PATCH 006/113] incremental commit for spatialstats documentation --- docs/source/biascorr.rst | 4 ++++ docs/source/index.rst | 1 + docs/source/intro.rst | 22 ++++++++++++---------- 3 files changed, 17 insertions(+), 10 deletions(-) create mode 100644 docs/source/biascorr.rst diff --git a/docs/source/biascorr.rst b/docs/source/biascorr.rst new file mode 100644 index 00000000..33af4b10 --- /dev/null +++ b/docs/source/biascorr.rst @@ -0,0 +1,4 @@ +Bias corrections +================ + +TODO \ No newline at end of file diff --git a/docs/source/index.rst b/docs/source/index.rst index fd3530c6..feeb6ab4 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -30,6 +30,7 @@ Simple usage tutorials intro coregistration + bias corr comparison spatialstats api/xdem.rst diff --git a/docs/source/intro.rst b/docs/source/intro.rst index c2b353b4..cee53d60 100644 --- a/docs/source/intro.rst +++ b/docs/source/intro.rst @@ -8,31 +8,33 @@ While some complexities are specific to certain instruments and methods, all DEM - an **georeferenced positioning subject to shifts, tilts or other deformations** due to inherent instrument errors, noise, or associated post-processing schemes, - a **large number of outliers** that can originate from various sources (e.g., photogrammetric blunders, clouds). -These factors lead to difficulties in assessing the accuracy and precision of DEMs, necessary to perform further analysis. +These factors lead to difficulties in assessing the accuracy and precision of DEMs, which are necessary to perform further analysis. + +In ``xdem``, we provide a framework with state-of-the-art methods published in the scientific literature to make DEM calculations consistent, reproducible, and easy. Accuracy and precision ********************** -Both accuracy and precision are important when analyzing DEMs: +Both accuracy and precision are important factors to account for when analyzing DEMs: - the **accuracy** (systematic error) of a DEM describes how close a DEM is to the true location of measured elevations on the Earth's surface, - the **precision** (random error) of a DEM describes the typical spread of its error in measurement, independently of a possible bias from the true positioning. Absolute or relative accuracy ***************************** -The measure of accuracy can be further split into two: - - the **absolute accuracy** of a DEM is the average shift to the true positioning. Studies interested in analyzing features of a single DEM in relation to other georeferenced data might give great importance to this potential bias. +The measure of accuracy can be further divided into two aspects: + - the **absolute accuracy** of a DEM describes the average shift to the true positioning. Studies interested in analyzing features of a single DEM in relation to other georeferenced data might give great importance to this potential bias. - the **relative accuracy** of a DEM is the potential shifts, tilts, and deformations in relation to other elevation data that does not necessarily matches a given referencing. Studies interested in comparing DEMs between themselves might be only interested in this accuracy. -Dealing with DEM absolute accuracy +Optimizing DEM absolute accuracy ********************************** -Shifts due to poor absolute accuracy are common in elevation datasets, and can be easily corrected by performing a DEM co-registration to accurate, georeferenced point elevation data such as ICESat and ICESat-2. +Shifts due to poor absolute accuracy are common in elevation datasets, and can be easily corrected by performing a DEM co-registration to precise and accurate, quality-controlled elevation data such as ICESat and ICESat-2. For more details, see :ref:`coregistration` with point data. -Dealing with DEM relative accuracy +Optimizing DEM relative accuracy ********************************** As the **absolute accuracy** can be corrected a posteriori using reference elevation datasets, many analyses only focus on **relative accuracy**, i.e. the remaining biases between several DEMs co-registered relative one to another. @@ -40,15 +42,15 @@ By harnessing the denser, nearly continuous sampling of raster DEMs (in oppositi - Terrain-related biases that can originate from the difference of resolution of DEMs, or instrument processing deformations. - Directional biases that can be linked to instrument noise, such as along-track oscillations observed in many widepsread DEM products (SRTM, ASTER, SPOT, Pléiades, etc). -Those biases can be tackled by iteratively combining co-registration and bias-correction methods (:ref:`coregistration`, TODO: ref bias corrections). +Those biases can be tackled by iteratively combining co-registration and bias-correction methods (:ref:`biascorr`). -Dealing with DEM precision +Quantifying DEM precision ************************** While dealing with **accuracy** is quite straightforward as it consists of minimizing the differences (biases) between several datasets, assessing the **precision** of DEMs can be much more complex. Measurement errors of a DEM cannot be quantified by a simple difference and require statistical inference. -The **precision** of DEMs has generally been reported by a single metric, for example: :math:`\pm` 2 m, but recent studies have shown the limitations of simplified metrics and provide more statistically advanced methods. +The **precision** of DEMs has historically been reported by a single metric, for example: :math:`\pm` 2 m, but recent studies have shown the limitations of such simplified metrics and provide more statistically-advanced methods. However, the lack of a simple implementation in a modern programming language makes these methods hard to reproduce and validate. One of the goals of ``xdem`` is to simplify state-of-the-art statistical measures, to allow accurate DEM uncertainty estimation for everyone, regardless of one's statistical talent. From 0ac7c3948c07e7afc9a53898903e9fa0367c5da7 Mon Sep 17 00:00:00 2001 From: rhugonne Date: Mon, 28 Jun 2021 12:40:21 +0200 Subject: [PATCH 007/113] incremental commit for spatialstats documentation --- docs/source/index.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/index.rst b/docs/source/index.rst index feeb6ab4..e5060b79 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -27,12 +27,12 @@ Simple usage :maxdepth: 2 :caption: Contents: - tutorials intro coregistration bias corr comparison spatialstats + tutorials api/xdem.rst Indices and tables From 1a030f5209cbfab5dc629f19fcdfc79c8dfadc99 Mon Sep 17 00:00:00 2001 From: rhugonne Date: Mon, 28 Jun 2021 12:53:25 +0200 Subject: [PATCH 008/113] incremental commit for spatialstats documentation --- docs/source/biascorr.rst | 2 ++ docs/source/intro.rst | 4 +++- docs/source/spatialstats.rst | 2 ++ 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/source/biascorr.rst b/docs/source/biascorr.rst index 33af4b10..4b33b156 100644 --- a/docs/source/biascorr.rst +++ b/docs/source/biascorr.rst @@ -1,3 +1,5 @@ +.. _biascorr: + Bias corrections ================ diff --git a/docs/source/intro.rst b/docs/source/intro.rst index cee53d60..2a3227a0 100644 --- a/docs/source/intro.rst +++ b/docs/source/intro.rst @@ -1,3 +1,5 @@ +.. _intro: + Introduction: why is it complex to assess DEM accuracy and precision? ===================================================================== @@ -42,7 +44,7 @@ By harnessing the denser, nearly continuous sampling of raster DEMs (in oppositi - Terrain-related biases that can originate from the difference of resolution of DEMs, or instrument processing deformations. - Directional biases that can be linked to instrument noise, such as along-track oscillations observed in many widepsread DEM products (SRTM, ASTER, SPOT, Pléiades, etc). -Those biases can be tackled by iteratively combining co-registration and bias-correction methods (:ref:`biascorr`). +Those biases can be tackled by iteratively combining co-registration and bias-correction methods (:ref:`coregistration`, :ref:`biascorr`). Quantifying DEM precision ************************** diff --git a/docs/source/spatialstats.rst b/docs/source/spatialstats.rst index 3b1a6328..1528f1f2 100644 --- a/docs/source/spatialstats.rst +++ b/docs/source/spatialstats.rst @@ -1,3 +1,5 @@ +.. _spatialstats: + Spatial statistics ================== From 7252e6dd9a273fe17da551be5c1906e0bd4b300d Mon Sep 17 00:00:00 2001 From: rhugonne Date: Mon, 28 Jun 2021 14:02:59 +0200 Subject: [PATCH 009/113] incremental commit for spatialstats documentation --- docs/source/spatialstats.rst | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/source/spatialstats.rst b/docs/source/spatialstats.rst index 1528f1f2..1e9b529e 100644 --- a/docs/source/spatialstats.rst +++ b/docs/source/spatialstats.rst @@ -62,6 +62,13 @@ Multi-range spatial correlations TODO: Add this section based Rolstad et al. (2009), Dehecq et al. (2020), Hugonnet et al. (in prep) +.. literalinclude:: code/spatialstats.py + :lines: 26-27 + +.. plot:: code/spatialstats_empiricalvgm.py + +.. plot:: code/spatialstats_model_vgm.py + Spatially integrated measurement errors ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ From f45bcab0e7fa2e9f96bfe15c25000a14dbb98e74 Mon Sep 17 00:00:00 2001 From: rhugonne Date: Mon, 28 Jun 2021 14:26:43 +0200 Subject: [PATCH 010/113] incremental commit for spatialstats documentation --- docs/source/code/spatialstats_empirical_vgm.py | 2 +- docs/source/code/spatialstats_model_vgm.py | 2 +- xdem/spstats.py | 3 +-- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/docs/source/code/spatialstats_empirical_vgm.py b/docs/source/code/spatialstats_empirical_vgm.py index 1d210c1f..eefe6bc5 100644 --- a/docs/source/code/spatialstats_empirical_vgm.py +++ b/docs/source/code/spatialstats_empirical_vgm.py @@ -33,6 +33,6 @@ # plot empirical variogram xdem.spstats.plot_vgm(df) -plt.show() +plt.show() diff --git a/docs/source/code/spatialstats_model_vgm.py b/docs/source/code/spatialstats_model_vgm.py index ef8651d2..ca06f597 100644 --- a/docs/source/code/spatialstats_model_vgm.py +++ b/docs/source/code/spatialstats_model_vgm.py @@ -37,6 +37,6 @@ # plot empirical variogram xdem.spstats.plot_vgm(df) -plt.show() +plt.show() diff --git a/xdem/spstats.py b/xdem/spstats.py index 4c88c068..29efb1b9 100644 --- a/xdem/spstats.py +++ b/xdem/spstats.py @@ -747,5 +747,4 @@ def plot_vgm(df: pd.DataFrame, list_fit_fun: Optional[list[Callable]] = None, li ax.set_xlabel('Lag (m)') ax.set_ylabel(r'Variance [$\mu$ $\pm \sigma$]') ax.legend(loc='best') - ax.grid() - plt.show() + ax.grid() \ No newline at end of file From 4cb4db432f0135ca04d5deeb5dd6633696e36a5d Mon Sep 17 00:00:00 2001 From: rhugonne Date: Mon, 28 Jun 2021 14:29:37 +0200 Subject: [PATCH 011/113] incremental commit for spatialstats documentation --- docs/source/spatialstats.rst | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/source/spatialstats.rst b/docs/source/spatialstats.rst index 1e9b529e..a7256eb0 100644 --- a/docs/source/spatialstats.rst +++ b/docs/source/spatialstats.rst @@ -65,7 +65,9 @@ TODO: Add this section based Rolstad et al. (2009), Dehecq et al. (2020), Hugonn .. literalinclude:: code/spatialstats.py :lines: 26-27 -.. plot:: code/spatialstats_empiricalvgm.py + +.. plot:: code/spatialstats_empirical_vgm.py + .. plot:: code/spatialstats_model_vgm.py From 769fade7bd6e48dfdfa343615779539c0980d7f1 Mon Sep 17 00:00:00 2001 From: rhugonne Date: Mon, 28 Jun 2021 14:43:46 +0200 Subject: [PATCH 012/113] incremental commit for spatialstats documentation --- docs/source/code/spatialstats_empirical_vgm.py | 2 +- xdem/spstats.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/source/code/spatialstats_empirical_vgm.py b/docs/source/code/spatialstats_empirical_vgm.py index eefe6bc5..fb609331 100644 --- a/docs/source/code/spatialstats_empirical_vgm.py +++ b/docs/source/code/spatialstats_empirical_vgm.py @@ -32,7 +32,7 @@ df = xdem.spstats.sample_multirange_empirical_variogram(dh=ddem.data, nsamp=1000, nrun=20, maxlag=10000) # plot empirical variogram -xdem.spstats.plot_vgm(df) +ax = xdem.spstats.plot_vgm(df) plt.show() diff --git a/xdem/spstats.py b/xdem/spstats.py index 29efb1b9..a0385ea1 100644 --- a/xdem/spstats.py +++ b/xdem/spstats.py @@ -747,4 +747,5 @@ def plot_vgm(df: pd.DataFrame, list_fit_fun: Optional[list[Callable]] = None, li ax.set_xlabel('Lag (m)') ax.set_ylabel(r'Variance [$\mu$ $\pm \sigma$]') ax.legend(loc='best') - ax.grid() \ No newline at end of file + + return ax \ No newline at end of file From c7591b36dd2828097fe469978c6778242beac56d Mon Sep 17 00:00:00 2001 From: rhugonne Date: Mon, 28 Jun 2021 15:13:08 +0200 Subject: [PATCH 013/113] incremental commit for spatialstats documentation --- docs/source/code/spatialstats_empirical_vgm.py | 4 ++-- docs/source/code/spatialstats_model_vgm.py | 6 +----- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/docs/source/code/spatialstats_empirical_vgm.py b/docs/source/code/spatialstats_empirical_vgm.py index fb609331..878752d0 100644 --- a/docs/source/code/spatialstats_empirical_vgm.py +++ b/docs/source/code/spatialstats_empirical_vgm.py @@ -29,10 +29,10 @@ np.random.seed(42) # sample empirical variogram -df = xdem.spstats.sample_multirange_empirical_variogram(dh=ddem.data, nsamp=1000, nrun=20, maxlag=10000) +df = xdem.spstats.sample_multirange_empirical_variogram(dh=ddem.data, gsd=ddem.res[0], nsamp=1000, nrun=20, maxlag=4000) # plot empirical variogram -ax = xdem.spstats.plot_vgm(df) +xdem.spstats.plot_vgm(df) plt.show() diff --git a/docs/source/code/spatialstats_model_vgm.py b/docs/source/code/spatialstats_model_vgm.py index ca06f597..39fc838d 100644 --- a/docs/source/code/spatialstats_model_vgm.py +++ b/docs/source/code/spatialstats_model_vgm.py @@ -29,14 +29,10 @@ np.random.seed(42) # sample empirical variogram -df = xdem.spstats.sample_multirange_empirical_variogram(dh=ddem.data, nsamp=1000, nrun=20, maxlag=10000) +df = xdem.spstats.sample_multirange_empirical_variogram(dh=ddem.data, gsd=ddem.res[0], nsamp=1000, nrun=20, maxlag=4000) fun, _ = xdem.spstats.fit_model_sum_vgm(['Sph'], df) fun2, _ = xdem.spstats.fit_model_sum_vgm(['Sph', 'Sph', 'Sph'], emp_vgm_df=df) xdem.spstats.plot_vgm(df, list_fit_fun=[fun,fun2],list_fit_fun_label=['Spherical model','Sum of three spherical models']) -# plot empirical variogram -xdem.spstats.plot_vgm(df) - plt.show() - From a6140ff2bdb09b1fc5484c0391d6bc30837cd4ff Mon Sep 17 00:00:00 2001 From: rhugonne Date: Mon, 28 Jun 2021 16:35:33 +0200 Subject: [PATCH 014/113] incremental commit for spatialstats documentation --- docs/source/spatialstats.rst | 45 +++++++++++++++++++++++++++++++----- 1 file changed, 39 insertions(+), 6 deletions(-) diff --git a/docs/source/spatialstats.rst b/docs/source/spatialstats.rst index a7256eb0..72b39896 100644 --- a/docs/source/spatialstats.rst +++ b/docs/source/spatialstats.rst @@ -6,9 +6,9 @@ Spatial statistics Spatial statistics, also referred to as geostatistics, are essential for the analysis of observations distributed in space. To analyze DEMs, ``xdem`` integrates spatial statistics tools specific to DEMs based on recent literature, and with routines partly relying on `scikit-gstat `_. -The spatial statistics tools can be used to: +The spatial statistics tools can be used to assess the precision of DEMs (see the definition of precision in :ref:`intro`), and in particular: - account for non-stationarities of elevation measurement errors (e.g., varying precision of DEMs with terrain slope), - - quantify the spatial correlation in DEMs (e.g., native spatial resolution, instrument noise), + - quantify the spatial correlation of measurement errors in DEMs (e.g., native spatial resolution, instrument noise), - estimate robust errors for observations integrated in space (e.g., average or sum of samples), - propagate errors between spatial ensembles at different scales (e.g., sum of glacier volume changes). @@ -17,13 +17,46 @@ More details below. .. contents:: Contents :local: +Assumptions for statistical inference in spatial statistics +*********************************************************** + +Spatial statistics are valid if the variable of interest verifies the assumption of stationarity of the 1:superscript:`st` and 2:superscript:`nd` orders. +That is, if the two following assumptions are verified: + 1. The mean of the variable of interest is stationary in space, i.e. constant over sufficiently large areas, + 2. The variance of the variable of interest is stationary in space, i.e. constant over sufficiently large areas. + +A sufficiently large averaging area is an area expected to fit within the spatial domain studied. + +In other words, for a reliable analysis, the DEM should: + 1. Not contain systematic biases that do not average to zero over sufficiently large distances (e.g., shifts, tilts), but can contain large-scale pseudo-periodic biases (e.g., along-track undulations), + 2. Not contain measurement errors that vary significantly. + +Precision of a single DEM, or a difference of elevation data +************************************************************ + +TO COMPLETE LATER IN MORE DETAILS WITH: Hugonnet et al. (in prep) + +To infer the precision of a DEM, it is compared against other elevation data. +If the other elevation data is known to be of higher-precision, one can assume that the analysis of differences will represent the precision of the rougher DEM. +Otherwise, the difference will describe the precision with significant measurement errors originating from both the DEM and the other dataset. + +Stable terrain: proxy for infering DEM precision +************************************************ + +To infer the precision of a DEM over all terrain, the proxy typically utilized is the stable terrain (i.e. terrain that has not moved such as bare rock). + +and after the removal of systematic biases to ensure an optimalaccuracy (see :ref:`intro`). + + + Metrics for DEM precision ************************* -Pixel-wise elevation measurement errors -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +Pixel-wise elevation measurement error +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +The Spatially-integrated elevation measurement error @@ -48,8 +81,8 @@ However, several issues arise to estimate the standard error of a mean of elevat Note that the SE represents completely stochastic (random) errors, and is therefore not accounting for possible remaining systematic errors have been accounted for, e.g. using one or multiple :ref:`coregistration` approaches. -Relative spatial accuracy of a DEM -********************************** +Methods for DEM precision estimation +************************************ Non-stationarity in elevation measurement errors From 99cfee447bfd21a9a46e08006b2f7ab82d1b6a02 Mon Sep 17 00:00:00 2001 From: rhugonne Date: Mon, 28 Jun 2021 16:42:29 +0200 Subject: [PATCH 015/113] incremental commit for spatialstats documentation --- docs/source/spatialstats.rst | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/docs/source/spatialstats.rst b/docs/source/spatialstats.rst index 72b39896..4fe604b3 100644 --- a/docs/source/spatialstats.rst +++ b/docs/source/spatialstats.rst @@ -6,7 +6,7 @@ Spatial statistics Spatial statistics, also referred to as geostatistics, are essential for the analysis of observations distributed in space. To analyze DEMs, ``xdem`` integrates spatial statistics tools specific to DEMs based on recent literature, and with routines partly relying on `scikit-gstat `_. -The spatial statistics tools can be used to assess the precision of DEMs (see the definition of precision in :ref:`intro`), and in particular: +The spatial statistics tools can be used to assess the precision of DEMs (see the definition of precision in :ref:`intro`). In particular, these tools help to: - account for non-stationarities of elevation measurement errors (e.g., varying precision of DEMs with terrain slope), - quantify the spatial correlation of measurement errors in DEMs (e.g., native spatial resolution, instrument noise), - estimate robust errors for observations integrated in space (e.g., average or sum of samples), @@ -20,7 +20,7 @@ More details below. Assumptions for statistical inference in spatial statistics *********************************************************** -Spatial statistics are valid if the variable of interest verifies the assumption of stationarity of the 1:superscript:`st` and 2:superscript:`nd` orders. +Spatial statistics are valid if the variable of interest verifies the assumption of stationarity of the 1:sup:`st` and 2:sup:`nd` orders. That is, if the two following assumptions are verified: 1. The mean of the variable of interest is stationary in space, i.e. constant over sufficiently large areas, 2. The variance of the variable of interest is stationary in space, i.e. constant over sufficiently large areas. @@ -28,27 +28,24 @@ That is, if the two following assumptions are verified: A sufficiently large averaging area is an area expected to fit within the spatial domain studied. In other words, for a reliable analysis, the DEM should: - 1. Not contain systematic biases that do not average to zero over sufficiently large distances (e.g., shifts, tilts), but can contain large-scale pseudo-periodic biases (e.g., along-track undulations), + 1. Not contain systematic biases that do not average out over sufficiently large distances (e.g., shifts, tilts), but can contain large-scale pseudo-periodic biases (e.g., along-track undulations), 2. Not contain measurement errors that vary significantly. Precision of a single DEM, or a difference of elevation data ************************************************************ -TO COMPLETE LATER IN MORE DETAILS WITH: Hugonnet et al. (in prep) - To infer the precision of a DEM, it is compared against other elevation data. If the other elevation data is known to be of higher-precision, one can assume that the analysis of differences will represent the precision of the rougher DEM. Otherwise, the difference will describe the precision with significant measurement errors originating from both the DEM and the other dataset. +TO DO: complete with Hugonnet et al. (in prep) + Stable terrain: proxy for infering DEM precision ************************************************ To infer the precision of a DEM over all terrain, the proxy typically utilized is the stable terrain (i.e. terrain that has not moved such as bare rock). -and after the removal of systematic biases to ensure an optimalaccuracy (see :ref:`intro`). - - - +However Metrics for DEM precision ************************* From 20d077ba91eae483e205eca296d40c5ac509174e Mon Sep 17 00:00:00 2001 From: rhugonne Date: Mon, 28 Jun 2021 16:59:00 +0200 Subject: [PATCH 016/113] incremental commit for spatialstats documentation --- docs/source/spatialstats.rst | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/docs/source/spatialstats.rst b/docs/source/spatialstats.rst index 4fe604b3..d463f68c 100644 --- a/docs/source/spatialstats.rst +++ b/docs/source/spatialstats.rst @@ -4,7 +4,7 @@ Spatial statistics ================== Spatial statistics, also referred to as geostatistics, are essential for the analysis of observations distributed in space. -To analyze DEMs, ``xdem`` integrates spatial statistics tools specific to DEMs based on recent literature, and with routines partly relying on `scikit-gstat `_. +To analyze DEMs, ``xdem`` integrates spatial statistics tools specific to DEMs described in recent literature, in particular `Rolstad et al. (2009) `_, `Dehecq et al. (2020) `_ and `Hugonnet et al. (2021) `_. The implementation of these methods relies partly on `scikit-gstat `_. The spatial statistics tools can be used to assess the precision of DEMs (see the definition of precision in :ref:`intro`). In particular, these tools help to: - account for non-stationarities of elevation measurement errors (e.g., varying precision of DEMs with terrain slope), @@ -12,15 +12,13 @@ The spatial statistics tools can be used to assess the precision of DEMs (see th - estimate robust errors for observations integrated in space (e.g., average or sum of samples), - propagate errors between spatial ensembles at different scales (e.g., sum of glacier volume changes). -More details below. - .. contents:: Contents :local: Assumptions for statistical inference in spatial statistics *********************************************************** -Spatial statistics are valid if the variable of interest verifies the assumption of stationarity of the 1:sup:`st` and 2:sup:`nd` orders. +Spatial statistics are valid if the variable of interest verifies the assumption of stationarity of the 1\ :sup:`st` and 2\ :sup:`nd` orders. That is, if the two following assumptions are verified: 1. The mean of the variable of interest is stationary in space, i.e. constant over sufficiently large areas, 2. The variance of the variable of interest is stationary in space, i.e. constant over sufficiently large areas. @@ -28,11 +26,11 @@ That is, if the two following assumptions are verified: A sufficiently large averaging area is an area expected to fit within the spatial domain studied. In other words, for a reliable analysis, the DEM should: - 1. Not contain systematic biases that do not average out over sufficiently large distances (e.g., shifts, tilts), but can contain large-scale pseudo-periodic biases (e.g., along-track undulations), - 2. Not contain measurement errors that vary significantly. + 1. Not contain systematic biases that do not average out over sufficiently large distances (e.g., shifts, tilts), but can contain pseudo-periodic biases (e.g., along-track undulations), + 2. Not contain measurement errors that vary significantly in space. -Precision of a single DEM, or a difference of elevation data -************************************************************ +Precision of a single DEM, or of a difference of elevation data +*************************************************************** To infer the precision of a DEM, it is compared against other elevation data. If the other elevation data is known to be of higher-precision, one can assume that the analysis of differences will represent the precision of the rougher DEM. @@ -45,7 +43,7 @@ Stable terrain: proxy for infering DEM precision To infer the precision of a DEM over all terrain, the proxy typically utilized is the stable terrain (i.e. terrain that has not moved such as bare rock). -However +However Metrics for DEM precision ************************* From c5b10e8185d718326428a7d9a9dba085edaff6d9 Mon Sep 17 00:00:00 2001 From: rhugonne Date: Mon, 28 Jun 2021 17:16:44 +0200 Subject: [PATCH 017/113] incremental commit for spatialstats documentation --- docs/source/intro.rst | 10 +++++----- docs/source/spatialstats.rst | 17 +++++------------ 2 files changed, 10 insertions(+), 17 deletions(-) diff --git a/docs/source/intro.rst b/docs/source/intro.rst index 2a3227a0..8a7bd227 100644 --- a/docs/source/intro.rst +++ b/docs/source/intro.rst @@ -33,8 +33,9 @@ Optimizing DEM absolute accuracy ********************************** Shifts due to poor absolute accuracy are common in elevation datasets, and can be easily corrected by performing a DEM co-registration to precise and accurate, quality-controlled elevation data such as ICESat and ICESat-2. +Quality-controlled DEMs aligned on high-accuracy data also exists, such as TanDEM-X global DEM (see `Rizzoli et al. (2017) `_) -For more details, see :ref:`coregistration` with point data. +Those biases can be corrected using the methods described in see :ref:`coregistration`. Optimizing DEM relative accuracy ********************************** @@ -44,7 +45,7 @@ By harnessing the denser, nearly continuous sampling of raster DEMs (in oppositi - Terrain-related biases that can originate from the difference of resolution of DEMs, or instrument processing deformations. - Directional biases that can be linked to instrument noise, such as along-track oscillations observed in many widepsread DEM products (SRTM, ASTER, SPOT, Pléiades, etc). -Those biases can be tackled by iteratively combining co-registration and bias-correction methods (:ref:`coregistration`, :ref:`biascorr`). +Those biases can be tackled by iteratively combining co-registration and bias-correction methods described in :ref:`coregistration` and :ref:`biascorr`. Quantifying DEM precision ************************** @@ -52,9 +53,8 @@ Quantifying DEM precision While dealing with **accuracy** is quite straightforward as it consists of minimizing the differences (biases) between several datasets, assessing the **precision** of DEMs can be much more complex. Measurement errors of a DEM cannot be quantified by a simple difference and require statistical inference. -The **precision** of DEMs has historically been reported by a single metric, for example: :math:`\pm` 2 m, but recent studies have shown the limitations of such simplified metrics and provide more statistically-advanced methods. -However, the lack of a simple implementation in a modern programming language makes these methods hard to reproduce and validate. -One of the goals of ``xdem`` is to simplify state-of-the-art statistical measures, to allow accurate DEM uncertainty estimation for everyone, regardless of one's statistical talent. +The **precision** of DEMs has historically been reported by a single metric (e.g., precision of :math:`\pm` 2 m), but recent studies have shown the limitations simple metrics and provide more statistically-advanced methods to account for potential variabilities and correlations in space. +The lack of implementations of these methods in a modern programming language makes them hard to reproduce and validate, and be applied consistently. This is why one of the main goals of ``xdem`` is to simplify state-of-the-art statistical measures, to allow accurate DEM uncertainty estimation for everyone, regardless of one's statistical talent. The tools for quantifying DEM precision are described in :ref:`spatialstats`. diff --git a/docs/source/spatialstats.rst b/docs/source/spatialstats.rst index d463f68c..73e7e41c 100644 --- a/docs/source/spatialstats.rst +++ b/docs/source/spatialstats.rst @@ -4,7 +4,7 @@ Spatial statistics ================== Spatial statistics, also referred to as geostatistics, are essential for the analysis of observations distributed in space. -To analyze DEMs, ``xdem`` integrates spatial statistics tools specific to DEMs described in recent literature, in particular `Rolstad et al. (2009) `_, `Dehecq et al. (2020) `_ and `Hugonnet et al. (2021) `_. The implementation of these methods relies partly on `scikit-gstat `_. +To analyze DEMs, ``xdem`` integrates spatial statistics tools specific to DEMs described in recent literature, in particular in `Rolstad et al. (2009) `_, `Dehecq et al. (2020) `_ and `Hugonnet et al. (2021) `_. The implementation of these methods relies partly on the package `scikit-gstat `_. The spatial statistics tools can be used to assess the precision of DEMs (see the definition of precision in :ref:`intro`). In particular, these tools help to: - account for non-stationarities of elevation measurement errors (e.g., varying precision of DEMs with terrain slope), @@ -36,14 +36,12 @@ To infer the precision of a DEM, it is compared against other elevation data. If the other elevation data is known to be of higher-precision, one can assume that the analysis of differences will represent the precision of the rougher DEM. Otherwise, the difference will describe the precision with significant measurement errors originating from both the DEM and the other dataset. -TO DO: complete with Hugonnet et al. (in prep) +TODO: complete with Hugonnet et al. (in prep) Stable terrain: proxy for infering DEM precision ************************************************ -To infer the precision of a DEM over all terrain, the proxy typically utilized is the stable terrain (i.e. terrain that has not moved such as bare rock). - -However +TODO Metrics for DEM precision ************************* @@ -51,7 +49,7 @@ Metrics for DEM precision Pixel-wise elevation measurement error ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -The +TODO Spatially-integrated elevation measurement error @@ -94,11 +92,6 @@ TODO: Add this section based Rolstad et al. (2009), Dehecq et al. (2020), Hugonn :lines: 26-27 -.. plot:: code/spatialstats_empirical_vgm.py - - -.. plot:: code/spatialstats_model_vgm.py - Spatially integrated measurement errors ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -107,4 +100,4 @@ TODO: Add this section based on Rolstad et al. (2009), Hugonnet et al. (in prep) Propagation of correlated errors ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -TODO: Add this section based on Krige's relation (Webster & Oliver, 2007 +TODO: Add this section based on Krige's relation (Webster & Oliver, 2007) From 71db6147a0cdea630dd695f1540450ff6efb4bd0 Mon Sep 17 00:00:00 2001 From: rhugonne Date: Mon, 28 Jun 2021 17:26:03 +0200 Subject: [PATCH 018/113] incremental commit for spatialstats documentation --- docs/source/intro.rst | 22 +++++++++++++--------- docs/source/spatialstats.rst | 29 +++++++++++++++++------------ 2 files changed, 30 insertions(+), 21 deletions(-) diff --git a/docs/source/intro.rst b/docs/source/intro.rst index 8a7bd227..a7861bcf 100644 --- a/docs/source/intro.rst +++ b/docs/source/intro.rst @@ -6,9 +6,10 @@ Introduction: why is it complex to assess DEM accuracy and precision? Digital Elevation Models are numerical representations of elevation. They are generated from different instruments (e.g., radiometer, radar, lidar), acquired in different conditions (e.g., ground, airborne, satellite), and using different post-processing techniques (e.g., stereophotogrammetry, interferometry). While some complexities are specific to certain instruments and methods, all DEMs generally have: - - an **arbitrary Ground Sampling Distance (GSD)** that does not necessarily represent their underlying spatial resolution, - - an **georeferenced positioning subject to shifts, tilts or other deformations** due to inherent instrument errors, noise, or associated post-processing schemes, - - a **large number of outliers** that can originate from various sources (e.g., photogrammetric blunders, clouds). + +- an **arbitrary Ground Sampling Distance (GSD)** that does not necessarily represent their underlying spatial resolution, +- an **georeferenced positioning subject to shifts, tilts or other deformations** due to inherent instrument errors, noise, or associated post-processing schemes, +- a **large number of outliers** that can originate from various sources (e.g., photogrammetric blunders, clouds). These factors lead to difficulties in assessing the accuracy and precision of DEMs, which are necessary to perform further analysis. @@ -18,15 +19,17 @@ Accuracy and precision ********************** Both accuracy and precision are important factors to account for when analyzing DEMs: - - the **accuracy** (systematic error) of a DEM describes how close a DEM is to the true location of measured elevations on the Earth's surface, - - the **precision** (random error) of a DEM describes the typical spread of its error in measurement, independently of a possible bias from the true positioning. + +- the **accuracy** (systematic error) of a DEM describes how close a DEM is to the true location of measured elevations on the Earth's surface, +- the **precision** (random error) of a DEM describes the typical spread of its error in measurement, independently of a possible bias from the true positioning. Absolute or relative accuracy ***************************** The measure of accuracy can be further divided into two aspects: - - the **absolute accuracy** of a DEM describes the average shift to the true positioning. Studies interested in analyzing features of a single DEM in relation to other georeferenced data might give great importance to this potential bias. - - the **relative accuracy** of a DEM is the potential shifts, tilts, and deformations in relation to other elevation data that does not necessarily matches a given referencing. Studies interested in comparing DEMs between themselves might be only interested in this accuracy. + +- the **absolute accuracy** of a DEM describes the average shift to the true positioning. Studies interested in analyzing features of a single DEM in relation to other georeferenced data might give great importance to this potential bias. +- the **relative accuracy** of a DEM is the potential shifts, tilts, and deformations in relation to other elevation data that does not necessarily matches a given referencing. Studies interested in comparing DEMs between themselves might be only interested in this accuracy. Optimizing DEM absolute accuracy @@ -42,8 +45,9 @@ Optimizing DEM relative accuracy As the **absolute accuracy** can be corrected a posteriori using reference elevation datasets, many analyses only focus on **relative accuracy**, i.e. the remaining biases between several DEMs co-registered relative one to another. By harnessing the denser, nearly continuous sampling of raster DEMs (in opposition to the sparser sampling of higher-accuracy point elevation data), one can identify and correct other types of biases: - - Terrain-related biases that can originate from the difference of resolution of DEMs, or instrument processing deformations. - - Directional biases that can be linked to instrument noise, such as along-track oscillations observed in many widepsread DEM products (SRTM, ASTER, SPOT, Pléiades, etc). + +- Terrain-related biases that can originate from the difference of resolution of DEMs, or instrument processing deformations. +- Directional biases that can be linked to instrument noise, such as along-track oscillations observed in many widepsread DEM products (SRTM, ASTER, SPOT, Pléiades, etc). Those biases can be tackled by iteratively combining co-registration and bias-correction methods described in :ref:`coregistration` and :ref:`biascorr`. diff --git a/docs/source/spatialstats.rst b/docs/source/spatialstats.rst index 73e7e41c..de3b1959 100644 --- a/docs/source/spatialstats.rst +++ b/docs/source/spatialstats.rst @@ -6,11 +6,13 @@ Spatial statistics Spatial statistics, also referred to as geostatistics, are essential for the analysis of observations distributed in space. To analyze DEMs, ``xdem`` integrates spatial statistics tools specific to DEMs described in recent literature, in particular in `Rolstad et al. (2009) `_, `Dehecq et al. (2020) `_ and `Hugonnet et al. (2021) `_. The implementation of these methods relies partly on the package `scikit-gstat `_. -The spatial statistics tools can be used to assess the precision of DEMs (see the definition of precision in :ref:`intro`). In particular, these tools help to: - - account for non-stationarities of elevation measurement errors (e.g., varying precision of DEMs with terrain slope), - - quantify the spatial correlation of measurement errors in DEMs (e.g., native spatial resolution, instrument noise), - - estimate robust errors for observations integrated in space (e.g., average or sum of samples), - - propagate errors between spatial ensembles at different scales (e.g., sum of glacier volume changes). +The spatial statistics tools can be used to assess the precision of DEMs (see the definition of precision in :ref:`intro`). +In particular, these tools help to: + +- account for non-stationarities of elevation measurement errors (e.g., varying precision of DEMs with terrain slope), +- quantify the spatial correlation of measurement errors in DEMs (e.g., native spatial resolution, instrument noise), +- estimate robust errors for observations integrated in space (e.g., average or sum of samples), +- propagate errors between spatial ensembles at different scales (e.g., sum of glacier volume changes). .. contents:: Contents :local: @@ -20,14 +22,16 @@ Assumptions for statistical inference in spatial statistics Spatial statistics are valid if the variable of interest verifies the assumption of stationarity of the 1\ :sup:`st` and 2\ :sup:`nd` orders. That is, if the two following assumptions are verified: - 1. The mean of the variable of interest is stationary in space, i.e. constant over sufficiently large areas, - 2. The variance of the variable of interest is stationary in space, i.e. constant over sufficiently large areas. + +1. The mean of the variable of interest is stationary in space, i.e. constant over sufficiently large areas, +2. The variance of the variable of interest is stationary in space, i.e. constant over sufficiently large areas. A sufficiently large averaging area is an area expected to fit within the spatial domain studied. In other words, for a reliable analysis, the DEM should: - 1. Not contain systematic biases that do not average out over sufficiently large distances (e.g., shifts, tilts), but can contain pseudo-periodic biases (e.g., along-track undulations), - 2. Not contain measurement errors that vary significantly in space. + +1. Not contain systematic biases that do not average out over sufficiently large distances (e.g., shifts, tilts), but can contain pseudo-periodic biases (e.g., along-track undulations), +2. Not contain measurement errors that vary significantly in space. Precision of a single DEM, or of a difference of elevation data *************************************************************** @@ -67,9 +71,10 @@ The standard error :math:`\sigma_{\overline{dh}}` of the mean :math:`\overline{ where :math:`\sigma_{dh}` is the dispersion of the samples, and :math:`N` is the number of **independent** observations. However, several issues arise to estimate the standard error of a mean of elevation observations samples: - 1. The dispersion :math:`\sigma_{dh}` cannot be estimated directly on changing terrain that we are usually interested in measuring (e.g., glacier, snow, forest). - 2. The dispersion :math:`\sigma_{dh}` typically shows important non-stationarities (e.g., an error 10 times as large on steep slopes than flat slopes). - 3. The number of samples :math:`N` is generally not equal to the number of sampled DEM pixels, as those are not independent in space and the Ground Sampling Distance of the DEM does not necessarily correspond to its effective resolution. + +1. The dispersion :math:`\sigma_{dh}` cannot be estimated directly on changing terrain that we are usually interested in measuring (e.g., glacier, snow, forest). +2. The dispersion :math:`\sigma_{dh}` typically shows important non-stationarities (e.g., an error 10 times as large on steep slopes than flat slopes). +3. The number of samples :math:`N` is generally not equal to the number of sampled DEM pixels, as those are not independent in space and the Ground Sampling Distance of the DEM does not necessarily correspond to its effective resolution. Note that the SE represents completely stochastic (random) errors, and is therefore not accounting for possible remaining systematic errors have been accounted for, e.g. using one or multiple :ref:`coregistration` approaches. From d613edf3229b120643d830b343ed71deb7ee042e Mon Sep 17 00:00:00 2001 From: rhugonne Date: Mon, 28 Jun 2021 17:36:13 +0200 Subject: [PATCH 019/113] incremental commit for spatialstats documentation --- docs/source/intro.rst | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/source/intro.rst b/docs/source/intro.rst index a7861bcf..39748470 100644 --- a/docs/source/intro.rst +++ b/docs/source/intro.rst @@ -23,6 +23,8 @@ Both accuracy and precision are important factors to account for when analyzing - the **accuracy** (systematic error) of a DEM describes how close a DEM is to the true location of measured elevations on the Earth's surface, - the **precision** (random error) of a DEM describes the typical spread of its error in measurement, independently of a possible bias from the true positioning. +TODO: Add a little schematic! + Absolute or relative accuracy ***************************** @@ -31,6 +33,7 @@ The measure of accuracy can be further divided into two aspects: - the **absolute accuracy** of a DEM describes the average shift to the true positioning. Studies interested in analyzing features of a single DEM in relation to other georeferenced data might give great importance to this potential bias. - the **relative accuracy** of a DEM is the potential shifts, tilts, and deformations in relation to other elevation data that does not necessarily matches a given referencing. Studies interested in comparing DEMs between themselves might be only interested in this accuracy. +TODO: Add another little schematic! Optimizing DEM absolute accuracy ********************************** @@ -38,7 +41,9 @@ Optimizing DEM absolute accuracy Shifts due to poor absolute accuracy are common in elevation datasets, and can be easily corrected by performing a DEM co-registration to precise and accurate, quality-controlled elevation data such as ICESat and ICESat-2. Quality-controlled DEMs aligned on high-accuracy data also exists, such as TanDEM-X global DEM (see `Rizzoli et al. (2017) `_) -Those biases can be corrected using the methods described in see :ref:`coregistration`. +Those biases can be corrected using the methods described in :ref:`coregistration`. + +TODO: Add a point data - DEM co-registration plot Optimizing DEM relative accuracy ********************************** @@ -51,6 +56,8 @@ By harnessing the denser, nearly continuous sampling of raster DEMs (in oppositi Those biases can be tackled by iteratively combining co-registration and bias-correction methods described in :ref:`coregistration` and :ref:`biascorr`. +TODO: Add a plot on co-registration + bias correction between two DEMs + Quantifying DEM precision ************************** @@ -62,3 +69,4 @@ The lack of implementations of these methods in a modern programming language ma The tools for quantifying DEM precision are described in :ref:`spatialstats`. +TODO: Add a plot summarizing a DEM precision quantification From e8f28a113697257d1dfc2a0ba343ba3a2876634e Mon Sep 17 00:00:00 2001 From: rhugonne Date: Mon, 28 Jun 2021 17:57:11 +0200 Subject: [PATCH 020/113] incremental commit for spatialstats documentation --- docs/source/biascorr.rst | 10 ++++++++++ docs/source/comparison.rst | 2 +- docs/source/coregistration.rst | 13 +++++++++++-- docs/source/index.rst | 3 ++- 4 files changed, 24 insertions(+), 4 deletions(-) diff --git a/docs/source/biascorr.rst b/docs/source/biascorr.rst index 4b33b156..77d684e3 100644 --- a/docs/source/biascorr.rst +++ b/docs/source/biascorr.rst @@ -3,4 +3,14 @@ Bias corrections ================ +Bias corrections correspond to transformations that cannot be described as a 3-dimensional affine function (see :ref:`coregistration`). + +Directional biases +****************** + +TODO + +Terrain biases +************** + TODO \ No newline at end of file diff --git a/docs/source/comparison.rst b/docs/source/comparison.rst index a712a6db..0719684d 100644 --- a/docs/source/comparison.rst +++ b/docs/source/comparison.rst @@ -1,4 +1,4 @@ -DEM subtraction and volume change +Differencing and volume change ================================= .. contents:: Contents diff --git a/docs/source/coregistration.rst b/docs/source/coregistration.rst index 4ca35674..ccd9a9cb 100644 --- a/docs/source/coregistration.rst +++ b/docs/source/coregistration.rst @@ -1,7 +1,16 @@ .. _coregistration: -DEM Coregistration -================== +Coregistration +=============== + +Coregistration between DEMs correspond to aligning the digital elevation models in three dimension. + +Transformations that can be described by a 3-dimensional affine function are included in coregistration methods. +Those transformations include : + +- vertical and horizontal shifts, +- rotations, +- stretching and squeezing. .. contents:: Contents :local: diff --git a/docs/source/index.rst b/docs/source/index.rst index e5060b79..890db8b8 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -29,9 +29,10 @@ Simple usage intro coregistration - bias corr + biascorr comparison spatialstats + robuststats tutorials api/xdem.rst From 0657daadc90b90332322393676e104daab72901e Mon Sep 17 00:00:00 2001 From: rhugonne Date: Mon, 28 Jun 2021 18:07:27 +0200 Subject: [PATCH 021/113] incremental commit for spatialstats documentation --- docs/source/intro.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/source/intro.rst b/docs/source/intro.rst index 39748470..c003a618 100644 --- a/docs/source/intro.rst +++ b/docs/source/intro.rst @@ -64,8 +64,8 @@ Quantifying DEM precision While dealing with **accuracy** is quite straightforward as it consists of minimizing the differences (biases) between several datasets, assessing the **precision** of DEMs can be much more complex. Measurement errors of a DEM cannot be quantified by a simple difference and require statistical inference. -The **precision** of DEMs has historically been reported by a single metric (e.g., precision of :math:`\pm` 2 m), but recent studies have shown the limitations simple metrics and provide more statistically-advanced methods to account for potential variabilities and correlations in space. -The lack of implementations of these methods in a modern programming language makes them hard to reproduce and validate, and be applied consistently. This is why one of the main goals of ``xdem`` is to simplify state-of-the-art statistical measures, to allow accurate DEM uncertainty estimation for everyone, regardless of one's statistical talent. +The **precision** of DEMs has historically been reported by a single metric (e.g., precision of :math:`\pm` 2 m), but recent studies have shown the limitations of such simple metrics and provide more statistically-advanced methods to account for potential variabilities in precision and related correlations in space. +However, the lack of implementations of these methods in a modern programming language makes them hard to reproduce, validate, and apply consistently. This is why one of the main goals of ``xdem`` is to simplify state-of-the-art statistical measures, to allow accurate DEM uncertainty estimation for everyone. The tools for quantifying DEM precision are described in :ref:`spatialstats`. From 34ca13adbf0c515fcde258f27c8e5872351ac0d6 Mon Sep 17 00:00:00 2001 From: rhugonne Date: Tue, 29 Jun 2021 14:01:45 +0200 Subject: [PATCH 022/113] accouting for pr comments --- docs/source/coregistration.rst | 10 +++++----- docs/source/intro.rst | 12 ++++++------ 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/docs/source/coregistration.rst b/docs/source/coregistration.rst index ccd9a9cb..dc2e9be8 100644 --- a/docs/source/coregistration.rst +++ b/docs/source/coregistration.rst @@ -5,12 +5,12 @@ Coregistration Coregistration between DEMs correspond to aligning the digital elevation models in three dimension. -Transformations that can be described by a 3-dimensional affine function are included in coregistration methods. -Those transformations include : +Transformations that can be described by a 3-dimensional `affine `_ function are included in coregistration methods. +Those transformations include for instance: -- vertical and horizontal shifts, -- rotations, -- stretching and squeezing. +- vertical and horizontal translations, +- rotations, reflections, +- scalings. .. contents:: Contents :local: diff --git a/docs/source/intro.rst b/docs/source/intro.rst index c003a618..f46e198a 100644 --- a/docs/source/intro.rst +++ b/docs/source/intro.rst @@ -3,12 +3,12 @@ Introduction: why is it complex to assess DEM accuracy and precision? ===================================================================== -Digital Elevation Models are numerical representations of elevation. They are generated from different instruments (e.g., radiometer, radar, lidar), acquired in different conditions (e.g., ground, airborne, satellite), and using different post-processing techniques (e.g., stereophotogrammetry, interferometry). +Digital Elevation Models are numerical, gridded representations of elevation. They are generated from different instruments (e.g., optical sensors, radar, lidar), acquired in different conditions (e.g., ground, airborne, satellite), and using different post-processing techniques (e.g., photogrammetry, interferometry). While some complexities are specific to certain instruments and methods, all DEMs generally have: -- an **arbitrary Ground Sampling Distance (GSD)** that does not necessarily represent their underlying spatial resolution, -- an **georeferenced positioning subject to shifts, tilts or other deformations** due to inherent instrument errors, noise, or associated post-processing schemes, +- a **ground sampling distance** (`GSD `_), or pixel size, that does not necessarily represent the underlying spatial resolution of the observations, +- a **georeferenced positioning that can subject to shifts, tilts or other deformations** due to inherent instrument errors, noise, or associated post-processing schemes, - a **large number of outliers** that can originate from various sources (e.g., photogrammetric blunders, clouds). These factors lead to difficulties in assessing the accuracy and precision of DEMs, which are necessary to perform further analysis. @@ -31,7 +31,7 @@ Absolute or relative accuracy The measure of accuracy can be further divided into two aspects: - the **absolute accuracy** of a DEM describes the average shift to the true positioning. Studies interested in analyzing features of a single DEM in relation to other georeferenced data might give great importance to this potential bias. -- the **relative accuracy** of a DEM is the potential shifts, tilts, and deformations in relation to other elevation data that does not necessarily matches a given referencing. Studies interested in comparing DEMs between themselves might be only interested in this accuracy. +- the **relative accuracy** of a DEM is related to the potential shifts, tilts, and deformations with reference to other elevation data that does not necessarily matches a given referencing. Studies interested in comparing DEMs between themselves might be only interested in this accuracy. TODO: Add another little schematic! @@ -51,8 +51,8 @@ Optimizing DEM relative accuracy As the **absolute accuracy** can be corrected a posteriori using reference elevation datasets, many analyses only focus on **relative accuracy**, i.e. the remaining biases between several DEMs co-registered relative one to another. By harnessing the denser, nearly continuous sampling of raster DEMs (in opposition to the sparser sampling of higher-accuracy point elevation data), one can identify and correct other types of biases: -- Terrain-related biases that can originate from the difference of resolution of DEMs, or instrument processing deformations. -- Directional biases that can be linked to instrument noise, such as along-track oscillations observed in many widepsread DEM products (SRTM, ASTER, SPOT, Pléiades, etc). +- Terrain-related biases that can originate from the difference of resolution of DEMs, or instrument processing deformations (e.g., curvature-related biases described in `Gardelle et al. (2012) `_). +- Directional biases that can be linked to instrument noise, such as along-track oscillations observed in many widepsread DEM products such as SRTM, ASTER, SPOT, Pléiades (e.g., `Girod et al. (2017) `_). Those biases can be tackled by iteratively combining co-registration and bias-correction methods described in :ref:`coregistration` and :ref:`biascorr`. From 2901182673392fcfae854d8f402f965ebd07c027 Mon Sep 17 00:00:00 2001 From: rhugonne Date: Tue, 29 Jun 2021 14:11:43 +0200 Subject: [PATCH 023/113] add accuracy/precision image --- docs/source/intro.rst | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/source/intro.rst b/docs/source/intro.rst index f46e198a..f78e704b 100644 --- a/docs/source/intro.rst +++ b/docs/source/intro.rst @@ -18,12 +18,13 @@ In ``xdem``, we provide a framework with state-of-the-art methods published in t Accuracy and precision ********************** -Both accuracy and precision are important factors to account for when analyzing DEMs: +Both `accuracy and precision `_ are important factors to account for when analyzing DEMs: - the **accuracy** (systematic error) of a DEM describes how close a DEM is to the true location of measured elevations on the Earth's surface, - the **precision** (random error) of a DEM describes the typical spread of its error in measurement, independently of a possible bias from the true positioning. -TODO: Add a little schematic! +.. image:: http://cdn.antarcticglaciers.org/wp-content/uploads/2013/11/precision_accuracy.png + :width: 600 Absolute or relative accuracy ***************************** From f20187b5347f9fff93c9a3a24a43571844f08852 Mon Sep 17 00:00:00 2001 From: rhugonne Date: Tue, 29 Jun 2021 14:29:08 +0200 Subject: [PATCH 024/113] incremental commit for spatialstats documentation --- docs/source/coregistration.rst | 3 +-- docs/source/intro.rst | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/source/coregistration.rst b/docs/source/coregistration.rst index dc2e9be8..4bc86f18 100644 --- a/docs/source/coregistration.rst +++ b/docs/source/coregistration.rst @@ -203,11 +203,10 @@ For larger rotations, ICP is the only reliable approach (but does not outperform coreg.ICP() + coreg.NuthKaab() - For large biases, rotations and high amounts of noise: .. code-block:: python - coreg.BiasCorr() + coreg.ICP() + coreg.NuthKaab() + coreg.VerticalShift() + coreg.ICP() + coreg.NuthKaab() diff --git a/docs/source/intro.rst b/docs/source/intro.rst index f78e704b..1233b6c9 100644 --- a/docs/source/intro.rst +++ b/docs/source/intro.rst @@ -23,7 +23,7 @@ Both `accuracy and precision Date: Tue, 29 Jun 2021 14:50:27 +0200 Subject: [PATCH 025/113] try fix for image display --- docs/source/intro.rst | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/source/intro.rst b/docs/source/intro.rst index 1233b6c9..30a6ca79 100644 --- a/docs/source/intro.rst +++ b/docs/source/intro.rst @@ -5,11 +5,11 @@ Introduction: why is it complex to assess DEM accuracy and precision? Digital Elevation Models are numerical, gridded representations of elevation. They are generated from different instruments (e.g., optical sensors, radar, lidar), acquired in different conditions (e.g., ground, airborne, satellite), and using different post-processing techniques (e.g., photogrammetry, interferometry). -While some complexities are specific to certain instruments and methods, all DEMs generally have: +While some complexities are specific to certain instruments and methods, all DEMs generally possess: -- a **ground sampling distance** (`GSD `_), or pixel size, that does not necessarily represent the underlying spatial resolution of the observations, -- a **georeferenced positioning that can subject to shifts, tilts or other deformations** due to inherent instrument errors, noise, or associated post-processing schemes, -- a **large number of outliers** that can originate from various sources (e.g., photogrammetric blunders, clouds). +- a `ground sampling distance `_ (GSD), or pixel size, **that does not necessarily represent the underlying spatial resolution of the observations**, +- a `georeferencing `_ **that can subject to shifts, tilts or other deformations** due to inherent instrument errors, noise, or associated post-processing schemes, +- a large number of `outliers `_ **that remain difficult to filter** as they can originate from various sources (e.g., photogrammetric blunders, clouds). These factors lead to difficulties in assessing the accuracy and precision of DEMs, which are necessary to perform further analysis. @@ -24,6 +24,7 @@ Both `accuracy and precision Date: Tue, 29 Jun 2021 15:09:04 +0200 Subject: [PATCH 026/113] add doc image locally --- docs/source/images/precision_accuracy.png | Bin 0 -> 166311 bytes docs/source/intro.rst | 4 +++- 2 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 docs/source/images/precision_accuracy.png diff --git a/docs/source/images/precision_accuracy.png b/docs/source/images/precision_accuracy.png new file mode 100644 index 0000000000000000000000000000000000000000..2ee72c2668e86f0c37865ae580d0ea9610925ff8 GIT binary patch literal 166311 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 fFLPSYy#supQo23S_7kAMgP@xl1~&??+Xep*S3Y Date: Tue, 29 Jun 2021 15:58:49 +0200 Subject: [PATCH 027/113] try static dir in conf --- docs/source/conf.py | 2 +- docs/source/{images => imgs}/precision_accuracy.png | Bin docs/source/intro.rst | 4 +--- 3 files changed, 2 insertions(+), 4 deletions(-) rename docs/source/{images => imgs}/precision_accuracy.png (100%) diff --git a/docs/source/conf.py b/docs/source/conf.py index 9342866f..115a8507 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -68,7 +68,7 @@ # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -# html_static_path = ['_static'] # Commented out as we have no custom static data +html_static_path = ['imgs'] # Commented out as we have no custom static data def run_apidoc(_): diff --git a/docs/source/images/precision_accuracy.png b/docs/source/imgs/precision_accuracy.png similarity index 100% rename from docs/source/images/precision_accuracy.png rename to docs/source/imgs/precision_accuracy.png diff --git a/docs/source/intro.rst b/docs/source/intro.rst index b7867495..ccfd552b 100644 --- a/docs/source/intro.rst +++ b/docs/source/intro.rst @@ -23,9 +23,7 @@ Both `accuracy and precision Date: Tue, 29 Jun 2021 16:03:38 +0200 Subject: [PATCH 028/113] add source link --- docs/source/intro.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/source/intro.rst b/docs/source/intro.rst index ccfd552b..492abbae 100644 --- a/docs/source/intro.rst +++ b/docs/source/intro.rst @@ -24,8 +24,9 @@ Both `accuracy and precision `_, accessed 29.06.21. Absolute or relative accuracy ***************************** From 2fee2b3fa4d01ba08984ad151f91ceb84683cc57 Mon Sep 17 00:00:00 2001 From: rhugonne Date: Tue, 29 Jun 2021 16:22:40 +0200 Subject: [PATCH 029/113] larger size image --- docs/source/intro.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/intro.rst b/docs/source/intro.rst index 492abbae..af3d1070 100644 --- a/docs/source/intro.rst +++ b/docs/source/intro.rst @@ -24,7 +24,7 @@ Both `accuracy and precision `_, accessed 29.06.21. From 90721c0fcb61fb2bb1adf594e3a9353de97c8589 Mon Sep 17 00:00:00 2001 From: rhugonne Date: Wed, 30 Jun 2021 11:30:38 +0200 Subject: [PATCH 030/113] incremental commit for spatialstats documentation --- docs/source/intro.rst | 2 +- docs/source/spatialstats.rst | 41 ++++++++++++++++++++++++------------ 2 files changed, 28 insertions(+), 15 deletions(-) diff --git a/docs/source/intro.rst b/docs/source/intro.rst index af3d1070..3e91297c 100644 --- a/docs/source/intro.rst +++ b/docs/source/intro.rst @@ -34,7 +34,7 @@ Absolute or relative accuracy The measure of accuracy can be further divided into two aspects: - the **absolute accuracy** of a DEM describes the average shift to the true positioning. Studies interested in analyzing features of a single DEM in relation to other georeferenced data might give great importance to this potential bias. -- the **relative accuracy** of a DEM is related to the potential shifts, tilts, and deformations with reference to other elevation data that does not necessarily matches a given referencing. Studies interested in comparing DEMs between themselves might be only interested in this accuracy. +- the **relative accuracy** of a DEM is related to the potential shifts, tilts, and deformations with reference to other elevation data that does not necessarily matches the true positioning. Studies interested in comparing DEMs between themselves might be only interested in this accuracy. TODO: Add another little schematic! diff --git a/docs/source/spatialstats.rst b/docs/source/spatialstats.rst index de3b1959..0cc72db8 100644 --- a/docs/source/spatialstats.rst +++ b/docs/source/spatialstats.rst @@ -3,8 +3,12 @@ Spatial statistics ================== -Spatial statistics, also referred to as geostatistics, are essential for the analysis of observations distributed in space. -To analyze DEMs, ``xdem`` integrates spatial statistics tools specific to DEMs described in recent literature, in particular in `Rolstad et al. (2009) `_, `Dehecq et al. (2020) `_ and `Hugonnet et al. (2021) `_. The implementation of these methods relies partly on the package `scikit-gstat `_. +Spatial statistics, also referred to as `geostatistics `_, are essential for the analysis of observations distributed in space. +To analyze DEMs, ``xdem`` integrates spatial statistics tools specific to DEMs described in recent literature, +in particular in `Rolstad et al. (2009) `_, +`Dehecq et al. (2020) `_ and +`Hugonnet et al. (2021) `_. The implementation of these methods relies +partly on the package `scikit-gstat `_. The spatial statistics tools can be used to assess the precision of DEMs (see the definition of precision in :ref:`intro`). In particular, these tools help to: @@ -20,11 +24,13 @@ In particular, these tools help to: Assumptions for statistical inference in spatial statistics *********************************************************** -Spatial statistics are valid if the variable of interest verifies the assumption of stationarity of the 1\ :sup:`st` and 2\ :sup:`nd` orders. -That is, if the two following assumptions are verified: +Spatial statistics are valid if the variable of interest verifies `the assumption of second-order stationarity +`_. +That is, if the three following assumptions are verified: 1. The mean of the variable of interest is stationary in space, i.e. constant over sufficiently large areas, 2. The variance of the variable of interest is stationary in space, i.e. constant over sufficiently large areas. +3. The covariance between two observations only depends on the spatial distance between them, i.e. no other factor than this distance plays a role in the spatial correlation of measurement errors. A sufficiently large averaging area is an area expected to fit within the spatial domain studied. @@ -32,24 +38,32 @@ In other words, for a reliable analysis, the DEM should: 1. Not contain systematic biases that do not average out over sufficiently large distances (e.g., shifts, tilts), but can contain pseudo-periodic biases (e.g., along-track undulations), 2. Not contain measurement errors that vary significantly in space. +3. Not contain factors that significantly affect the spatial distribution of measurement errors, except for the spatial distance. -Precision of a single DEM, or of a difference of elevation data -*************************************************************** +Quantifying the precision of a single DEM, or of a difference of DEMs +********************************************************************* + +To statistically infer the precision of a DEM, the DEM has to be compared against independent elevation observations. -To infer the precision of a DEM, it is compared against other elevation data. If the other elevation data is known to be of higher-precision, one can assume that the analysis of differences will represent the precision of the rougher DEM. -Otherwise, the difference will describe the precision with significant measurement errors originating from both the DEM and the other dataset. +Otherwise, significant measurement errors can originate from both sets of elevation observations, and the analysis of differences will represent the mixed precision of the two. TODO: complete with Hugonnet et al. (in prep) Stable terrain: proxy for infering DEM precision ************************************************ -TODO +When comparing elevation datasets, stable terrain is usually used a proxy Metrics for DEM precision ************************* +The precision of DEMs has generally been reported as a single value indicating the random error at the scale of a single pixel, for example :math:`\pm 2` meters. + +However, the significant variability of elevation measurement errors has been noted +In Hugonnet et al. (in prep), + + Pixel-wise elevation measurement error ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -79,14 +93,13 @@ However, several issues arise to estimate the standard error of a mean of elevat Note that the SE represents completely stochastic (random) errors, and is therefore not accounting for possible remaining systematic errors have been accounted for, e.g. using one or multiple :ref:`coregistration` approaches. -Methods for DEM precision estimation -************************************ - +Workflow for DEM precision estimation +************************************* Non-stationarity in elevation measurement errors ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -TODO: Add this section based on Hugonnet et al. (in prep) +TODO: Add this section based on Hugonnet et al. (in prep) Multi-range spatial correlations ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -105,4 +118,4 @@ TODO: Add this section based on Rolstad et al. (2009), Hugonnet et al. (in prep) Propagation of correlated errors ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -TODO: Add this section based on Krige's relation (Webster & Oliver, 2007) +TODO: Add this section based on Krige's relation (Webster & Oliver, 2007), Hugonnet et al. (in prep) From 4b2fa107e926f7597b100971e57cdd61e2c07f56 Mon Sep 17 00:00:00 2001 From: rhugonne Date: Wed, 30 Jun 2021 13:54:33 +0200 Subject: [PATCH 031/113] incremental commit for spatialstats documentation --- docs/source/spatialstats.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/spatialstats.rst b/docs/source/spatialstats.rst index 0cc72db8..6b22f489 100644 --- a/docs/source/spatialstats.rst +++ b/docs/source/spatialstats.rst @@ -38,7 +38,7 @@ In other words, for a reliable analysis, the DEM should: 1. Not contain systematic biases that do not average out over sufficiently large distances (e.g., shifts, tilts), but can contain pseudo-periodic biases (e.g., along-track undulations), 2. Not contain measurement errors that vary significantly in space. -3. Not contain factors that significantly affect the spatial distribution of measurement errors, except for the spatial distance. +3. Not contain factors that significantly affect the distribution of measurement errors, except for the spatial distance. Quantifying the precision of a single DEM, or of a difference of DEMs ********************************************************************* From ecde5de368dfb369b339128aefba1e641d8da024 Mon Sep 17 00:00:00 2001 From: rhugonne Date: Wed, 30 Jun 2021 18:40:34 +0200 Subject: [PATCH 032/113] incremental commit for spatialstats documentation --- docs/source/code/spatialstats.py | 33 +++++++--------- .../source/code/spatialstats_empirical_vgm.py | 38 ------------------- docs/source/code/spatialstats_model_vgm.py | 38 ------------------- docs/source/code/spatialstats_plot_vgm.py | 26 +++++++++++++ docs/source/spatialstats.rst | 34 ++++++++++++++--- 5 files changed, 67 insertions(+), 102 deletions(-) delete mode 100644 docs/source/code/spatialstats_empirical_vgm.py delete mode 100644 docs/source/code/spatialstats_model_vgm.py create mode 100644 docs/source/code/spatialstats_plot_vgm.py diff --git a/docs/source/code/spatialstats.py b/docs/source/code/spatialstats.py index 2d0d3c7c..5547f51a 100644 --- a/docs/source/code/spatialstats.py +++ b/docs/source/code/spatialstats.py @@ -2,35 +2,28 @@ import geoutils as gu import numpy as np -# load diff and mask -xdem.examples.download_longyearbyen_examples(overwrite=False) - -reference_raster = gu.georaster.Raster(xdem.examples.FILEPATHS["longyearbyen_ref_dem"]) -to_be_aligned_raster = gu.georaster.Raster(xdem.examples.FILEPATHS["longyearbyen_tba_dem"]) -glacier_mask = gu.geovector.Vector(xdem.examples.FILEPATHS["longyearbyen_glacier_outlines"]) -inlier_mask = ~glacier_mask.create_mask(reference_raster) +# Load data +ddem = gu.georaster.Raster(xdem.examples.get_path("longyearbyen_ddem")) +glacier_mask = gu.geovector.Vector(xdem.examples.get_path("longyearbyen_glacier_outlines")) +mask = glacier_mask.create_mask(ddem) -nuth_kaab = xdem.coreg.NuthKaab() -nuth_kaab.fit(reference_raster.data, to_be_aligned_raster.data, - inlier_mask=inlier_mask, transform=reference_raster.transform) -aligned_raster = nuth_kaab.apply(to_be_aligned_raster.data, transform=reference_raster.transform) +# Get slope for non-stationarity +slope = xdem.coreg.calculate_slope_and_aspect(ddem.data)[0] -ddem = gu.Raster.from_array((reference_raster.data - aligned_raster), - transform=reference_raster.transform, crs=reference_raster.crs) -mask = glacier_mask.create_mask(ddem) +# Keep only stable terrain data +ddem.data[mask] = np.nan -# ddem is a difference of DEMs -x, y = ddem.coords(offset='center') -coords = np.dstack((x.flatten(), y.flatten())).squeeze() +# Get non-stationarities by bins +df_ns = xdem.spstats.nd_binning(ddem.data.ravel(),list_var=[slope.ravel()],list_var_names=['slope']) # Sample empirical variogram -df = xdem.spstats.sample_multirange_empirical_variogram(dh=ddem.data, nsamp=1000, nrun=20, nproc=10, maxlag=10000) +df_vgm = xdem.spstats.sample_multirange_empirical_variogram(dh=ddem.data, nsamp=1000, nrun=20, nproc=10, maxlag=10000) # Fit single-range spherical model -fun, coefs = xdem.spstats.fit_model_sum_vgm(['Sph'], df) +fun, coefs = xdem.spstats.fit_model_sum_vgm(['Sph'], emp_vgm_df=df_vgm) # Fit sum of triple-range spherical model -fun2, coefs2 = xdem.spstats.fit_model_sum_vgm(['Sph', 'Sph', 'Sph'], emp_vgm_df=df) +fun2, coefs2 = xdem.spstats.fit_model_sum_vgm(['Sph', 'Sph', 'Sph'], emp_vgm_df=df_vgm) # Calculate the area-averaged uncertainty with these models list_vgm = [(coefs[2*i],'Sph',coefs[2*i+1]) for i in range(int(len(coefs)/2))] diff --git a/docs/source/code/spatialstats_empirical_vgm.py b/docs/source/code/spatialstats_empirical_vgm.py deleted file mode 100644 index 878752d0..00000000 --- a/docs/source/code/spatialstats_empirical_vgm.py +++ /dev/null @@ -1,38 +0,0 @@ -import matplotlib.pyplot as plt - -import geoutils as gu -import xdem -import numpy as np - -# load diff and mask -xdem.examples.download_longyearbyen_examples(overwrite=False) - -reference_raster = gu.georaster.Raster(xdem.examples.FILEPATHS["longyearbyen_ref_dem"]) -to_be_aligned_raster = gu.georaster.Raster(xdem.examples.FILEPATHS["longyearbyen_tba_dem"]) -glacier_mask = gu.geovector.Vector(xdem.examples.FILEPATHS["longyearbyen_glacier_outlines"]) -inlier_mask = ~glacier_mask.create_mask(reference_raster) - -nuth_kaab = xdem.coreg.NuthKaab() -nuth_kaab.fit(reference_raster.data, to_be_aligned_raster.data, - inlier_mask=inlier_mask, transform=reference_raster.transform) -aligned_raster = nuth_kaab.apply(to_be_aligned_raster.data, transform=reference_raster.transform) - -ddem = gu.Raster.from_array((reference_raster.data - aligned_raster), - transform=reference_raster.transform, crs=reference_raster.crs) -mask = glacier_mask.create_mask(ddem) - -# extract coordinates -x, y = ddem.coords(offset='center') -coords = np.dstack((x.flatten(), y.flatten())).squeeze() - -# ensure the figures are reproducible -np.random.seed(42) - -# sample empirical variogram -df = xdem.spstats.sample_multirange_empirical_variogram(dh=ddem.data, gsd=ddem.res[0], nsamp=1000, nrun=20, maxlag=4000) - -# plot empirical variogram -xdem.spstats.plot_vgm(df) - -plt.show() - diff --git a/docs/source/code/spatialstats_model_vgm.py b/docs/source/code/spatialstats_model_vgm.py deleted file mode 100644 index 39fc838d..00000000 --- a/docs/source/code/spatialstats_model_vgm.py +++ /dev/null @@ -1,38 +0,0 @@ -import matplotlib.pyplot as plt - -import geoutils as gu -import xdem -import numpy as np - -# load diff and mask -xdem.examples.download_longyearbyen_examples(overwrite=False) - -reference_raster = gu.georaster.Raster(xdem.examples.FILEPATHS["longyearbyen_ref_dem"]) -to_be_aligned_raster = gu.georaster.Raster(xdem.examples.FILEPATHS["longyearbyen_tba_dem"]) -glacier_mask = gu.geovector.Vector(xdem.examples.FILEPATHS["longyearbyen_glacier_outlines"]) -inlier_mask = ~glacier_mask.create_mask(reference_raster) - -nuth_kaab = xdem.coreg.NuthKaab() -nuth_kaab.fit(reference_raster.data, to_be_aligned_raster.data, - inlier_mask=inlier_mask, transform=reference_raster.transform) -aligned_raster = nuth_kaab.apply(to_be_aligned_raster.data, transform=reference_raster.transform) - -ddem = gu.Raster.from_array((reference_raster.data - aligned_raster), - transform=reference_raster.transform, crs=reference_raster.crs) -mask = glacier_mask.create_mask(ddem) - -# extract coordinates -x, y = ddem.coords(offset='center') -coords = np.dstack((x.flatten(), y.flatten())).squeeze() - -# ensure the figures are reproducible -np.random.seed(42) - -# sample empirical variogram -df = xdem.spstats.sample_multirange_empirical_variogram(dh=ddem.data, gsd=ddem.res[0], nsamp=1000, nrun=20, maxlag=4000) - -fun, _ = xdem.spstats.fit_model_sum_vgm(['Sph'], df) -fun2, _ = xdem.spstats.fit_model_sum_vgm(['Sph', 'Sph', 'Sph'], emp_vgm_df=df) -xdem.spstats.plot_vgm(df, list_fit_fun=[fun,fun2],list_fit_fun_label=['Spherical model','Sum of three spherical models']) - -plt.show() diff --git a/docs/source/code/spatialstats_plot_vgm.py b/docs/source/code/spatialstats_plot_vgm.py new file mode 100644 index 00000000..f813e6ac --- /dev/null +++ b/docs/source/code/spatialstats_plot_vgm.py @@ -0,0 +1,26 @@ +import matplotlib.pyplot as plt + +import geoutils as gu +import xdem +import numpy as np + +# load data +ddem = gu.georaster.Raster(xdem.examples.get_path("longyearbyen_ddem")) +glacier_mask = gu.geovector.Vector(xdem.examples.get_path("longyearbyen_glacier_outlines")) +mask = glacier_mask.create_mask(ddem) + +# remove glacier data +ddem.data[mask] = np.nan + +# ensure the figures are reproducible +np.random.seed(42) + +# sample empirical variogram +df = xdem.spstats.sample_multirange_empirical_variogram(dh=ddem.data, gsd=ddem.res[0], nsamp=1000, nrun=20, maxlag=4000) + +fun, _ = xdem.spstats.fit_model_sum_vgm(['Sph'], df) +fun2, _ = xdem.spstats.fit_model_sum_vgm(['Sph', 'Sph', 'Sph'], emp_vgm_df=df) +xdem.spstats.plot_vgm(df, list_fit_fun=[fun, fun2],list_fit_fun_label=['Spherical model','Sum of three spherical models']) + +plt.show() + diff --git a/docs/source/spatialstats.rst b/docs/source/spatialstats.rst index 6b22f489..89d0afa5 100644 --- a/docs/source/spatialstats.rst +++ b/docs/source/spatialstats.rst @@ -60,9 +60,7 @@ Metrics for DEM precision The precision of DEMs has generally been reported as a single value indicating the random error at the scale of a single pixel, for example :math:`\pm 2` meters. -However, the significant variability of elevation measurement errors has been noted -In Hugonnet et al. (in prep), - +However, the significant variability of elevation measurement errors Pixel-wise elevation measurement error ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -99,16 +97,40 @@ Workflow for DEM precision estimation Non-stationarity in elevation measurement errors ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +Quantify and model non-stationarites +"""""""""""""""""""""""""""""""""""" + TODO: Add this section based on Hugonnet et al. (in prep) -Multi-range spatial correlations -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +.. literalinclude:: code/spatialstats.py + :lines: 16-17 + +Standardize elevation differences for further analysis +"""""""""""""""""""""""""""""""""""""""""""""""""""""" + + +Spatial correlation of elevation measurement errors +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ TODO: Add this section based Rolstad et al. (2009), Dehecq et al. (2020), Hugonnet et al. (in prep) +Quantify and model spatial correlations +""""""""""""""""""""""""""""""""""""""" + +.. literalinclude:: code/spatialstats.py + :lines: 19-20 + +For a single range model: + +.. literalinclude:: code/spatialstats.py + :lines: 22-23 + +For multiple range model: + .. literalinclude:: code/spatialstats.py - :lines: 26-27 + :lines: 25-26 +.. plot:: code/spatialstats_plot_vgm.py Spatially integrated measurement errors ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ From 48f73ccacac405ee2d4ffbd4583008061a7e3ba4 Mon Sep 17 00:00:00 2001 From: rhugonne Date: Thu, 1 Jul 2021 17:56:02 +0200 Subject: [PATCH 033/113] incremental commit for spatialstats documentation --- docs/source/spatialstats.rst | 90 +++++++++++++++++++++--------------- 1 file changed, 52 insertions(+), 38 deletions(-) diff --git a/docs/source/spatialstats.rst b/docs/source/spatialstats.rst index 89d0afa5..2a8ad38f 100644 --- a/docs/source/spatialstats.rst +++ b/docs/source/spatialstats.rst @@ -21,8 +21,11 @@ In particular, these tools help to: .. contents:: Contents :local: +Spatial statistics for DEM precision estimation +*********************************************** + Assumptions for statistical inference in spatial statistics -*********************************************************** +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Spatial statistics are valid if the variable of interest verifies `the assumption of second-order stationarity `_. @@ -41,55 +44,28 @@ In other words, for a reliable analysis, the DEM should: 3. Not contain factors that significantly affect the distribution of measurement errors, except for the spatial distance. Quantifying the precision of a single DEM, or of a difference of DEMs -********************************************************************* +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ To statistically infer the precision of a DEM, the DEM has to be compared against independent elevation observations. If the other elevation data is known to be of higher-precision, one can assume that the analysis of differences will represent the precision of the rougher DEM. -Otherwise, significant measurement errors can originate from both sets of elevation observations, and the analysis of differences will represent the mixed precision of the two. - -TODO: complete with Hugonnet et al. (in prep) - -Stable terrain: proxy for infering DEM precision -************************************************ - -When comparing elevation datasets, stable terrain is usually used a proxy - -Metrics for DEM precision -************************* - -The precision of DEMs has generally been reported as a single value indicating the random error at the scale of a single pixel, for example :math:`\pm 2` meters. - -However, the significant variability of elevation measurement errors - -Pixel-wise elevation measurement error -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -TODO - - -Spatially-integrated elevation measurement error -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -The standard error (SE) of a statistic is the standard deviation of the distribution of this statistic. -For spatially distributed samples, the standard error of the mean (SEM) is of great interest as it allows quantification of the error of a mean (or sum) of samples in space. - -The standard error :math:`\sigma_{\overline{dh}}` of the mean :math:`\overline{dh}` of elevation changes samples :math:`dh` is typically derived as: .. math:: + \sigma_{dh} = \sigma_{h_{\textrm{higher precision}} - h_{\textrm{lower precision}}} \approx \sigma_{h_{\textrm{lower precision}}} - \sigma_{\overline{dh}} = \frac{\sigma_{dh}}{\sqrt{N}}, +Otherwise, significant measurement errors can originate from both sets of elevation observations, and the analysis of differences will represent the mixed precision of the two. +As there is no reason a priori for a depedency between the elevation data sets, the analysis will yield: -where :math:`\sigma_{dh}` is the dispersion of the samples, and :math:`N` is the number of **independent** observations. +.. math:: + \sigma_{dh} = \sigma_{h_{\textrm{precision1}} - h_{\textrm{precision2}}} = \sqrt{\sigma_{h_{\textrm{precision1}}}^{2} + \sigma_{h_{\textrm{precision2}}}^{2}} -However, several issues arise to estimate the standard error of a mean of elevation observations samples: -1. The dispersion :math:`\sigma_{dh}` cannot be estimated directly on changing terrain that we are usually interested in measuring (e.g., glacier, snow, forest). -2. The dispersion :math:`\sigma_{dh}` typically shows important non-stationarities (e.g., an error 10 times as large on steep slopes than flat slopes). -3. The number of samples :math:`N` is generally not equal to the number of sampled DEM pixels, as those are not independent in space and the Ground Sampling Distance of the DEM does not necessarily correspond to its effective resolution. +TODO: complete with Hugonnet et al. (in prep) -Note that the SE represents completely stochastic (random) errors, and is therefore not accounting for possible remaining systematic errors have been accounted for, e.g. using one or multiple :ref:`coregistration` approaches. +Using stable terrain as a proxy +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +When comparing elevation datasets, stable terrain is usually used a proxy Workflow for DEM precision estimation ************************************* @@ -141,3 +117,41 @@ Propagation of correlated errors ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ TODO: Add this section based on Krige's relation (Webster & Oliver, 2007), Hugonnet et al. (in prep) + + +Metrics for DEM precision +************************* + +Historically, the precision of DEMs has been reported as a single value indicating the random error at the scale of a single pixel, for example :math:`\pm 2` meters. + +However, there is several limitations to this metric: +- studies have shown significant variability of elevation measurement errors with terrain attributes, such as the slope, but also with the type of terrain + + +Pixel-wise elevation measurement error +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +TODO + + +Spatially-integrated elevation measurement error +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The standard error (SE) of a statistic is the standard deviation of the distribution of this statistic. +For spatially distributed samples, the standard error of the mean (SEM) is of great interest as it allows quantification of the error of a mean (or sum) of samples in space. + +The standard error :math:`\sigma_{\overline{dh}}` of the mean :math:`\overline{dh}` of elevation changes samples :math:`dh` is typically derived as: + +.. math:: + + \sigma_{\overline{dh}} = \frac{\sigma_{dh}}{\sqrt{N}}, + +where :math:`\sigma_{dh}` is the dispersion of the samples, and :math:`N` is the number of **independent** observations. + +However, several issues arise to estimate the standard error of a mean of elevation observations samples: + +1. The dispersion :math:`\sigma_{dh}` cannot be estimated directly on changing terrain that we are usually interested in measuring (e.g., glacier, snow, forest). +2. The dispersion :math:`\sigma_{dh}` typically shows important non-stationarities (e.g., an error 10 times as large on steep slopes than flat slopes). +3. The number of samples :math:`N` is generally not equal to the number of sampled DEM pixels, as those are not independent in space and the Ground Sampling Distance of the DEM does not necessarily correspond to its effective resolution. + +Note that the SE represents completely stochastic (random) errors, and is therefore not accounting for possible remaining systematic errors have been accounted for, e.g. using one or multiple :ref:`coregistration` approaches. From ebef6d37164884fb58be25986db4dfa6beb4f130 Mon Sep 17 00:00:00 2001 From: rhugonne Date: Fri, 2 Jul 2021 17:30:02 +0200 Subject: [PATCH 034/113] testing image width as percentage --- docs/source/intro.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/intro.rst b/docs/source/intro.rst index 3e91297c..95da155c 100644 --- a/docs/source/intro.rst +++ b/docs/source/intro.rst @@ -24,7 +24,7 @@ Both `accuracy and precision `_, accessed 29.06.21. From eabfcd8358962e52781e30c9bff4a7745f9972c5 Mon Sep 17 00:00:00 2001 From: rhugonne Date: Fri, 2 Jul 2021 17:30:20 +0200 Subject: [PATCH 035/113] fix testing image width as percentage --- docs/source/intro.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/intro.rst b/docs/source/intro.rst index 95da155c..45e7d06e 100644 --- a/docs/source/intro.rst +++ b/docs/source/intro.rst @@ -24,7 +24,7 @@ Both `accuracy and precision `_, accessed 29.06.21. From 94c5e0fc6d8d10d1cd82f4c9bc3b63199abcb224 Mon Sep 17 00:00:00 2001 From: rhugonne Date: Sat, 3 Jul 2021 18:42:26 +0200 Subject: [PATCH 036/113] add plot 1d and 2d functions for binning --- examples/plot_nonstationary_error.py | 38 +++++ xdem/spstats.py | 242 ++++++++++++++++++++++++++- 2 files changed, 276 insertions(+), 4 deletions(-) create mode 100644 examples/plot_nonstationary_error.py diff --git a/examples/plot_nonstationary_error.py b/examples/plot_nonstationary_error.py new file mode 100644 index 00000000..50080c58 --- /dev/null +++ b/examples/plot_nonstationary_error.py @@ -0,0 +1,38 @@ +""" +Non-stationarities in measurement errors +""" +# sphinx_gallery_thumbnail_number = 2 +import matplotlib.pyplot as plt + +import numpy as np +import xdem +import geoutils as gu + +# %% +# **Example files** +ref_dem = xdem.DEM(xdem.examples.get_path("longyearbyen_ref_dem")) +ddem = xdem.DEM(xdem.examples.get_path("longyearbyen_ddem")) + +glacier_outlines = gu.Vector(xdem.examples.get_path("longyearbyen_glacier_outlines")) +mask_glacier = glacier_outlines.create_mask(ddem) + +# Plot +def plot_1d_binning(df, l) +ax, fig = plt.subplots() +plt.plot(df.) + +# Mask out unstable terrain +ddem.data[mask_glacier] = np.nan + +# Get terrain variables +slope, aspect, planc, profc = \ + xdem.terrain.get_terrain_attribute(dem=ref_dem.data, + attribute=['slope','aspect', 'planform_curvature', 'profile_curvature'], + resolution=ref_dem.res) + +# Look at possible non-stationarities with terrain variables +df = xdem.spstats.nd_binning(values=ddem.data,list_var=[slope, aspect, planc, profc], + list_var_names=['slope','aspect','planc','profc'], + statistics=['count',np.nanmedian,xdem.spstats.nmad]) + + diff --git a/xdem/spstats.py b/xdem/spstats.py index 319e8570..e30feb22 100644 --- a/xdem/spstats.py +++ b/xdem/spstats.py @@ -12,6 +12,7 @@ import itertools import matplotlib.pyplot as plt +import matplotlib.colors as colors from numba import njit import numpy as np import pandas as pd @@ -68,7 +69,7 @@ def interp_nd_binning(df: pd.DataFrame, list_var_names: Union[str,list[str]], st # Extrapolated linearly outside the 2D frame. >>> fun((-1, 1)) - array(-5.) + array(-1.) """ # if list of variable input is simply a string if isinstance(list_var_names,str): @@ -1003,8 +1004,16 @@ def patches_method(dh : np.ndarray, mask: np.ndarray[bool], gsd : float, area_si return df -def plot_vgm(df: pd.DataFrame, list_fit_fun: Optional[list[Callable]] = None, list_fit_fun_label: Optional[list[str]] = None): - +def plot_vgm(df: pd.DataFrame, list_fit_fun: Optional[list[Callable[[float],float]]] = None, + list_fit_fun_label: Optional[list[str]] = None): + """ + Plot empirical variogram, with optionally one or several model fits + :param df: dataframe of empirical variogram + :param list_fit_fun: list of function fits + :param list_fit_fun_label: list of function fits labels + :param + :return: + """ fig, ax = plt.subplots(1) if np.all(np.isnan(df.exp_sigma)): ax.scatter(df.bins, df.exp, label='Empirical variogram', color='blue') @@ -1028,4 +1037,229 @@ def plot_vgm(df: pd.DataFrame, list_fit_fun: Optional[list[Callable]] = None, li ax.set_ylabel(r'Variance [$\mu$ $\pm \sigma$]') ax.legend(loc='best') - return ax \ No newline at end of file + return ax + +def plot_1d_binning(df: pd.DataFrame, var_name: str, statistic_name: str, label_var: Optional[str] = None, + label_statistic: Optional[str] = None, min_count: int = 30): + """ + Plot one statistic and its count along a single binning variable. + Input is expected to be formatted as the output of the nd_binning function. + + :param df: output dataframe of nd_binning + :param var_name: name of binning variable to plot + :param statistic_name: name of statistic of interest to plot + :param label_var: label of binning variable + :param label_statistic: label of statistic of interest + :param min_count: removes statistic values computed with a count inferior to this minimum value + """ + + if label_var is None: + label_var = var_name + if label_statistic is None: + label_statistic = statistic_name + + # Subsample to 1D and for the variable of interest + df_sub = df[np.logical_and(df.nd == 1, np.isfinite(pd.IntervalIndex(df[var_name]).mid))] + # Remove statistic calculated in bins with too low count + df_sub.loc[df_sub['count'] Date: Sat, 3 Jul 2021 18:46:16 +0200 Subject: [PATCH 037/113] remove remaining download example lines --- examples/plot_blockwise_coreg.py | 2 -- examples/plot_norm_regional_hypso.py | 2 -- 2 files changed, 4 deletions(-) diff --git a/examples/plot_blockwise_coreg.py b/examples/plot_blockwise_coreg.py index cf461238..9939a37b 100644 --- a/examples/plot_blockwise_coreg.py +++ b/examples/plot_blockwise_coreg.py @@ -25,8 +25,6 @@ # %% # **Example files** -xdem.examples.download_longyearbyen_examples() - reference_dem = xdem.DEM(xdem.examples.get_path("longyearbyen_ref_dem")) dem_to_be_aligned = xdem.DEM(xdem.examples.get_path("longyearbyen_tba_dem")) glacier_outlines = gu.Vector(xdem.examples.get_path("longyearbyen_glacier_outlines")) diff --git a/examples/plot_norm_regional_hypso.py b/examples/plot_norm_regional_hypso.py index 78b94f25..b20087e7 100644 --- a/examples/plot_norm_regional_hypso.py +++ b/examples/plot_norm_regional_hypso.py @@ -37,8 +37,6 @@ # %% # **Example files** -xdem.examples.download_longyearbyen_examples() - dem_2009 = xdem.DEM(xdem.examples.get_path("longyearbyen_ref_dem")) dem_1990 = xdem.DEM(xdem.examples.get_path("longyearbyen_tba_dem_coreg")) From b3679a4a0b19ee27c3132c4434d88c0e69bc6ec6 Mon Sep 17 00:00:00 2001 From: rhugonne Date: Sat, 3 Jul 2021 18:58:55 +0200 Subject: [PATCH 038/113] first draft example nonstationarity --- examples/plot_nonstationary_error.py | 70 +++++++++++++++++++++++++--- 1 file changed, 64 insertions(+), 6 deletions(-) diff --git a/examples/plot_nonstationary_error.py b/examples/plot_nonstationary_error.py index 50080c58..57d2c08b 100644 --- a/examples/plot_nonstationary_error.py +++ b/examples/plot_nonstationary_error.py @@ -3,8 +3,10 @@ """ # sphinx_gallery_thumbnail_number = 2 import matplotlib.pyplot as plt +import matplotlib.colors as colors import numpy as np +import pandas as pd import xdem import geoutils as gu @@ -16,11 +18,6 @@ glacier_outlines = gu.Vector(xdem.examples.get_path("longyearbyen_glacier_outlines")) mask_glacier = glacier_outlines.create_mask(ddem) -# Plot -def plot_1d_binning(df, l) -ax, fig = plt.subplots() -plt.plot(df.) - # Mask out unstable terrain ddem.data[mask_glacier] = np.nan @@ -33,6 +30,67 @@ def plot_1d_binning(df, l) # Look at possible non-stationarities with terrain variables df = xdem.spstats.nd_binning(values=ddem.data,list_var=[slope, aspect, planc, profc], list_var_names=['slope','aspect','planc','profc'], - statistics=['count',np.nanmedian,xdem.spstats.nmad]) + statistics=['count',np.nanmedian,xdem.spstats.nmad], + list_var_bins=10) + +# Let's look at each variable +xdem.spstats.plot_1d_binning(df, 'slope', 'nmad', 'Slope (degrees)', 'NMAD of elevation differences (m)') +xdem.spstats.plot_1d_binning(df, 'aspect', 'nmad', 'Aspect (degrees)', 'NMAD of elevation differences (m)') +xdem.spstats.plot_1d_binning(df, 'planc', 'nmad', 'Planform curvature (100 m$^{-1}$)', 'NMAD of elevation differences (m)') +xdem.spstats.plot_1d_binning(df, 'profc', 'nmad', 'Profile curvature (100 m$^{-1}$)', 'NMAD of elevation differences (m)') + +# There is a clear dependency to slope, none clear with aspect, but it is ambiguous with the curvature +# We should better define our bins to avoid sampling bins with too many or too few samples +# For this, we can partition the data in quantile when calling nd_binning. +# Note: we need a higher number of bins to work with quantiles and estimate the edges of the distribution +# As with N dimensions the N dimensional bin size increases exponentially, we avoid binning all variables at the same +# and bin one by one +df = xdem.spstats.nd_binning(values=ddem.data,list_var=[slope], list_var_names=['slope'], + statistics=['count',np.nanmedian,xdem.spstats.nmad], + list_var_bins=[[np.nanquantile(slope,0.002*i) for i in range(501)]]) +xdem.spstats.plot_1d_binning(df, 'slope', 'nmad', 'Slope (degrees)', 'NMAD of elevation differences (m)') +df = xdem.spstats.nd_binning(values=ddem.data,list_var=[profc], list_var_names=['profc'], + statistics=['count',np.nanmedian,xdem.spstats.nmad], + list_var_bins=[[np.nanquantile(profc,0.005*i) for i in range(201)]]) +xdem.spstats.plot_1d_binning(df, 'profc', 'nmad', 'Profile curvature (100 m$^{-1}$)', 'NMAD of elevation differences (m)') + +df = xdem.spstats.nd_binning(values=ddem.data,list_var=[planc], list_var_names=['planc'], + statistics=['count',np.nanmedian,xdem.spstats.nmad], + list_var_bins=[[np.nanquantile(planc,0.005*i) for i in range(201)]]) +xdem.spstats.plot_1d_binning(df, 'planc', 'nmad', 'Planform curvature (100 m$^{-1}$)', 'NMAD of elevation differences (m)') + +# We see there is a clear relation with plan and profile curvatures, that is symmetrical and similar for both types of curvature. +# Thus, we can here use the maximum absolute curvature to simplify our analysis + +# Derive maximum absolute curvature +maxc = np.maximum(np.abs(planc),np.abs(profc)) +df = xdem.spstats.nd_binning(values=ddem.data,list_var=[maxc], list_var_names=['maxc'], + statistics=['count',np.nanmedian,xdem.spstats.nmad], + list_var_bins=[[np.nanquantile(maxc,0.002*i) for i in range(501)]]) +xdem.spstats.plot_1d_binning(df, 'maxc', 'nmad', 'Maximum absolute curvature (100 m$^{-1}$)', 'NMAD of elevation differences (m)') + +# There is indeed a clear relation with curvature as well ! +# But, high curvatures might occur more often around steep slopes, so what if those dependencies are one and the same? +# We should explore the inter-dependency of slope and curvature + +df = xdem.spstats.nd_binning(values=ddem.data,list_var=[slope, maxc], list_var_names=['slope','maxc'], + statistics=['count',np.nanmedian,xdem.spstats.nmad], + list_var_bins=10) + +xdem.spstats.plot_2d_binning(df, 'slope', 'maxc', 'nmad', 'Slope (degrees)', 'Maximum absolute curvature (100 m$^{-1}$)', 'NMAD of dh (m)') + +# We can't see much with uniform bins again, let's try to use quantiles for both binning variables, and * +# adjust the plot scale to display those quantiles properly. +df = xdem.spstats.nd_binning(values=ddem.data,list_var=[slope, maxc], list_var_names=['slope','maxc'], + statistics=['count',np.nanmedian,xdem.spstats.nmad], + list_var_bins=[[np.nanquantile(slope,0.05*i) for i in range(21)],[np.nanquantile(maxc,0.025*i) for i in range(41)]]) +xdem.spstats.plot_2d_binning(df, 'slope', 'maxc', 'nmad', 'Slope (degrees)', 'Maximum absolute curvature (100 m$^{-1}$)', 'NMAD of dh (m)', scale_var_2='log', vmin=2, vmax=5) + +# We can see that both variable have an effect on the precision: +# - high-curvature at low slopes have larger errors +# - high-slopes at low curvature have larger errors as well +# We should thus try to account for both of those dependencies. + + From c4317b7862d6d2b9458a4d2bd0cddc1d80e22372 Mon Sep 17 00:00:00 2001 From: rhugonne Date: Sat, 3 Jul 2021 19:05:48 +0200 Subject: [PATCH 039/113] playing with sphinx gallery --- examples/plot_nonstationary_error.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/examples/plot_nonstationary_error.py b/examples/plot_nonstationary_error.py index 57d2c08b..65d02945 100644 --- a/examples/plot_nonstationary_error.py +++ b/examples/plot_nonstationary_error.py @@ -1,5 +1,7 @@ """ Non-stationarities in measurement errors +======================================== +Test """ # sphinx_gallery_thumbnail_number = 2 import matplotlib.pyplot as plt From cb02ff7abd85bcec2b15b964171b6a77d529e803 Mon Sep 17 00:00:00 2001 From: rhugonne Date: Sun, 4 Jul 2021 15:35:35 +0200 Subject: [PATCH 040/113] nonstationarity gallery example --- examples/plot_nonstationary_error.py | 201 ++++++++++++++++++++------- xdem/spstats.py | 4 +- 2 files changed, 155 insertions(+), 50 deletions(-) diff --git a/examples/plot_nonstationary_error.py b/examples/plot_nonstationary_error.py index 65d02945..413cf234 100644 --- a/examples/plot_nonstationary_error.py +++ b/examples/plot_nonstationary_error.py @@ -1,97 +1,202 @@ """ -Non-stationarities in measurement errors -======================================== -Test +Non-stationarities in elevation measurement errors +================================================== + +Digital elevation models have a precision that can vary with terrain and instrument-related variables. However, quantifying +this precision is complex and non-stationarities, i.e. variability of the measurement error, has rarely been +accounted for continuously with some studies using arbitrary thresholds on the slope or other variables (see :ref:`intro`). + +Quantifying the non-stationarities in elevation measurement errors is essential to use stable terrain as a proxy for +assessing the precision on other types of terrain (Hugonnet et al., in prep) and allows to standardize the measurement +errors to reach a stationary variance, an assumption necessary for spatial statistics (see :ref`spatialstats`). + +Here, we show an example in which we identify terrain-related non-stationarities for a DEM difference of Longyearbyen glacier. +We quantify those non-stationarities by `binning `_ robustly +in N-dimension using :func:`xdem.spstats.nd_binning` and applying a N-dimensional interpolation +:func:`xdem.spstats.interp_nd_binning` to estimate a numerical function of the measurement error and derive the spatial +distribution of elevation measurement errors of the difference of DEMs. + +**Reference**: `Hugonnet et al. (2021) `_, applied to the terrain slope +and quality of stereo-correlation (Equation 1, Extended Data Fig. 3a). """ -# sphinx_gallery_thumbnail_number = 2 +# sphinx_gallery_thumbnail_number = 10 import matplotlib.pyplot as plt -import matplotlib.colors as colors - import numpy as np -import pandas as pd import xdem import geoutils as gu # %% -# **Example files** +# We start by loading example files of a difference of DEMs at Longyearbyen glacier, the reference DEM to later derive +# terrain attribute, and the outlines to rasterize a glacier mask. +# Prior to differencing, the DEMs were aligned using :class:`xdem.coreg.NuthKaab` as shown in +# the :ref:`sphx_glr_auto_examples_plot_nuth_kaab.py` example. We later refer to those elevation differences as *dh*. + +# We load the data ref_dem = xdem.DEM(xdem.examples.get_path("longyearbyen_ref_dem")) ddem = xdem.DEM(xdem.examples.get_path("longyearbyen_ddem")) - glacier_outlines = gu.Vector(xdem.examples.get_path("longyearbyen_glacier_outlines")) +# Rasterize a glacier mask mask_glacier = glacier_outlines.create_mask(ddem) - -# Mask out unstable terrain +# Remove values on unstable terrain ddem.data[mask_glacier] = np.nan -# Get terrain variables +# %% +# We use the reference DEM to derive terrain variables such as slope, aspect, curvature that we'll use to explore potential +# non-stationarities in elevation measurement error. + +# We compute the slope, aspect, and both plan and profile curvatures slope, aspect, planc, profc = \ xdem.terrain.get_terrain_attribute(dem=ref_dem.data, attribute=['slope','aspect', 'planform_curvature', 'profile_curvature'], resolution=ref_dem.res) -# Look at possible non-stationarities with terrain variables +# %% +# We use :func:`xdem.spstats.nd_binning` to perform N-dimensional binning on all those terrain variables, with uniform +# bin length divided by 30. We use the :ref:`spatial_stats_nmad` as a robust measure of `statistical dispersion `_. + df = xdem.spstats.nd_binning(values=ddem.data,list_var=[slope, aspect, planc, profc], list_var_names=['slope','aspect','planc','profc'], statistics=['count',np.nanmedian,xdem.spstats.nmad], - list_var_bins=10) - -# Let's look at each variable -xdem.spstats.plot_1d_binning(df, 'slope', 'nmad', 'Slope (degrees)', 'NMAD of elevation differences (m)') -xdem.spstats.plot_1d_binning(df, 'aspect', 'nmad', 'Aspect (degrees)', 'NMAD of elevation differences (m)') -xdem.spstats.plot_1d_binning(df, 'planc', 'nmad', 'Planform curvature (100 m$^{-1}$)', 'NMAD of elevation differences (m)') -xdem.spstats.plot_1d_binning(df, 'profc', 'nmad', 'Profile curvature (100 m$^{-1}$)', 'NMAD of elevation differences (m)') - -# There is a clear dependency to slope, none clear with aspect, but it is ambiguous with the curvature -# We should better define our bins to avoid sampling bins with too many or too few samples -# For this, we can partition the data in quantile when calling nd_binning. -# Note: we need a higher number of bins to work with quantiles and estimate the edges of the distribution -# As with N dimensions the N dimensional bin size increases exponentially, we avoid binning all variables at the same -# and bin one by one -df = xdem.spstats.nd_binning(values=ddem.data,list_var=[slope], list_var_names=['slope'], - statistics=['count',np.nanmedian,xdem.spstats.nmad], - list_var_bins=[[np.nanquantile(slope,0.002*i) for i in range(501)]]) -xdem.spstats.plot_1d_binning(df, 'slope', 'nmad', 'Slope (degrees)', 'NMAD of elevation differences (m)') + list_var_bins=30) + +# %% +# We obtain a dataframe with the 1D binning results for each variable, the 2D binning results for all combinations of +# variables and the N-D (here 4D) binning with all variables. +# We can now visualize the results of the 1D binning of the computed NMAD of elevation differences with each variable +# using :func:`xdem.spstats.plot_1d_binning`. +# We can start with the slope that has been long known to be related to the elevation measurement error (e.g., +# `Toutin (2002) `_). +xdem.spstats.plot_1d_binning(df, 'slope', 'nmad', 'Slope (degrees)', 'NMAD of dh (m)') + +# %% +# We identify a clear variability, with the dispersion estimated from the NMAD increasing from ~2 meters for nearly flat +# slopes to above 12 meters for slopes steeper than 50°. +# In statistical terms, such a variability of `variance `_ is referred as +# `heteroscedasticity `_. Here we observe heteroscedastic elevation +# differences due to a non-stationarity of variance with the terrain slope. +# What about the aspect? + +xdem.spstats.plot_1d_binning(df, 'aspect', 'nmad', 'Aspect (degrees)', 'NMAD of dh (m)') + +# %% +# There is no variability with the aspect which shows a dispersion averaging 2-3 meters, i.e. that of the complete sample. +# And what about the profile curvature? + +xdem.spstats.plot_1d_binning(df, 'planc', 'nmad', 'Planform curvature (100 m$^{-1}$)', 'NMAD of dh (m)') + +# %% +# The relation with the profile curvature remains ambiguous. +# We should better define our bins to avoid sampling bins with too many or too few samples. For this, we can partition +# the data in quantiles in :func:`xdem.spstats.nd_binning`. +# Note: we need a higher number of bins to work with quantiles and still resolve the edges of the distribution. Thus, as +# with many dimensions the N dimensional bin size increases exponentially, we avoid binning all variables at the same +# time and instead bin one at a time. +# We define 200 quantile bins of size 0.005 (equivalent to 0.5% percentile bins) for the profile curvature: + df = xdem.spstats.nd_binning(values=ddem.data,list_var=[profc], list_var_names=['profc'], statistics=['count',np.nanmedian,xdem.spstats.nmad], list_var_bins=[[np.nanquantile(profc,0.005*i) for i in range(201)]]) -xdem.spstats.plot_1d_binning(df, 'profc', 'nmad', 'Profile curvature (100 m$^{-1}$)', 'NMAD of elevation differences (m)') +xdem.spstats.plot_1d_binning(df, 'profc', 'nmad', 'Profile curvature (100 m$^{-1}$)', 'NMAD of dh (m)') + +# %% +# We now clearly identify the variability with the profile curvature, from 2 meters for low curvatures to above 6 meters +# for higher positive or negative curvature. +# What about the role of the plan curvature? df = xdem.spstats.nd_binning(values=ddem.data,list_var=[planc], list_var_names=['planc'], statistics=['count',np.nanmedian,xdem.spstats.nmad], list_var_bins=[[np.nanquantile(planc,0.005*i) for i in range(201)]]) -xdem.spstats.plot_1d_binning(df, 'planc', 'nmad', 'Planform curvature (100 m$^{-1}$)', 'NMAD of elevation differences (m)') +xdem.spstats.plot_1d_binning(df, 'planc', 'nmad', 'Planform curvature (100 m$^{-1}$)', 'NMAD of dh (m)') -# We see there is a clear relation with plan and profile curvatures, that is symmetrical and similar for both types of curvature. -# Thus, we can here use the maximum absolute curvature to simplify our analysis +# %% +# The plan curvature shows a similar relation. Those are symmetrical with 0, and almost equal for both types of curvature. +# To simplify the analysis, we here combine those curvatures into the maximum absolute curvature: # Derive maximum absolute curvature maxc = np.maximum(np.abs(planc),np.abs(profc)) df = xdem.spstats.nd_binning(values=ddem.data,list_var=[maxc], list_var_names=['maxc'], statistics=['count',np.nanmedian,xdem.spstats.nmad], list_var_bins=[[np.nanquantile(maxc,0.002*i) for i in range(501)]]) -xdem.spstats.plot_1d_binning(df, 'maxc', 'nmad', 'Maximum absolute curvature (100 m$^{-1}$)', 'NMAD of elevation differences (m)') +xdem.spstats.plot_1d_binning(df, 'maxc', 'nmad', 'Maximum absolute curvature (100 m$^{-1}$)', 'NMAD of dh (m)') -# There is indeed a clear relation with curvature as well ! -# But, high curvatures might occur more often around steep slopes, so what if those dependencies are one and the same? -# We should explore the inter-dependency of slope and curvature +# %% +# Here's our simplified relation! We now have both slope and maximum absolute curvature with clear variability of +# the elevation measurement error. +# +# **But, one might rightfully wonder: high curvatures might occur more often around steep slopes than flat slope, +# so what if those two dependencies are actually one and the same?** +# We need to explore the variability with both slope and curvature at the same time: df = xdem.spstats.nd_binning(values=ddem.data,list_var=[slope, maxc], list_var_names=['slope','maxc'], statistics=['count',np.nanmedian,xdem.spstats.nmad], - list_var_bins=10) + list_var_bins=30) xdem.spstats.plot_2d_binning(df, 'slope', 'maxc', 'nmad', 'Slope (degrees)', 'Maximum absolute curvature (100 m$^{-1}$)', 'NMAD of dh (m)') -# We can't see much with uniform bins again, let's try to use quantiles for both binning variables, and * -# adjust the plot scale to display those quantiles properly. +# %% +# We can see that part of the variability seems to be independent, but with uniform bins the distribution across the range +# is not well represented. +# If we use quantiles for both binning variables, and adjust the plot scale: + +custom_bin_slope = np.unique([np.quantile(slope,0.05*i) for i in range(20)] + + [np.quantile(slope,0.95+0.02*i) for i in range(2)] + + [np.quantile(slope,0.98 + 0.005*i) for i in range(3)] + + [np.quantile(slope,0.995 + 0.001*i) for i in range(6)]) + +custom_bin_curvature = np.unique([np.quantile(maxc,0.05*i) for i in range(20)] + + [np.quantile(maxc,0.95+0.02*i) for i in range(2)] + + [np.quantile(maxc,0.98 + 0.005*i) for i in range(3)] + + [np.quantile(maxc,0.995 + 0.001*i) for i in range(6)]) + df = xdem.spstats.nd_binning(values=ddem.data,list_var=[slope, maxc], list_var_names=['slope','maxc'], statistics=['count',np.nanmedian,xdem.spstats.nmad], - list_var_bins=[[np.nanquantile(slope,0.05*i) for i in range(21)],[np.nanquantile(maxc,0.025*i) for i in range(41)]]) -xdem.spstats.plot_2d_binning(df, 'slope', 'maxc', 'nmad', 'Slope (degrees)', 'Maximum absolute curvature (100 m$^{-1}$)', 'NMAD of dh (m)', scale_var_2='log', vmin=2, vmax=5) + list_var_bins=[custom_bin_slope,custom_bin_curvature]) +xdem.spstats.plot_2d_binning(df, 'slope', 'maxc', 'nmad', 'Slope (degrees)', 'Maximum absolute curvature (100 m$^{-1}$)', 'NMAD of dh (m)', scale_var_2='log', vmin=2, vmax=10) + + +# %% +# We identify that the two variables have an independent effect on the precision: +# +# - *high curvatures and flat slopes* have larger errors than *low curvatures and flat slopes* +# - *steep slopes and low curvatures* have larger errors than *low curvatures and flat slopes* as well +# +# To later account for the non-stationarities identified, the simplest approach is a numerical approximation i.e. a piecewise +# linear interpolation/extrapolation of the binning results. +# To ensure that only robust statistic values are used in the interpolation, we set a ``min_count`` value at 100 samples. + +slope_curv_to_dh_err = xdem.spstats.interp_nd_binning(df,list_var_names=['slope','maxc'],statistic='nmad',min_count=100) + +# %% The output is an interpolant function of slope and curvature that we can use to estimate the elevation measurement +# error at any point. +# +# For instance, estimate the elevation measurement error for +# +# - a slope of 0 degrees and a maximum absolute curvature of 0 m\ :sup:`-1`\ , +# - a slope of 40 degrees and a maximum absolute curvature of 0 m\ :sup:`-1`\ , +# - a slope of 0 degrees and a maximum absolute curvature of 0.05 m\ :sup:`-1`\ , +# - a slope of 40 degrees and a maximum absolute curvature of 0.05 m\ :sup:`-1`\ . + +print(slope_curv_to_dh_err((0,0))) +print(slope_curv_to_dh_err((40,0))) +print(slope_curv_to_dh_err((0,5))) +print(slope_curv_to_dh_err((40,5))) + +# %% +# The same function can be used to estimate the spatial distribution of the elevation measurement error over the area: +dh_err = slope_curv_to_dh_err((slope,maxc)) + +plt.figure(figsize=(8, 5)) +plt_extent = [ + ref_dem.bounds.left, + ref_dem.bounds.right, + ref_dem.bounds.bottom, + ref_dem.bounds.top, +] +plt.imshow(dh_err.squeeze(), cmap="Reds", vmin=2, vmax=6, extent=plt_extent) +cbar = plt.colorbar() +cbar.set_label('Elevation measurement error (m)') +plt.show() -# We can see that both variable have an effect on the precision: -# - high-curvature at low slopes have larger errors -# - high-slopes at low curvature have larger errors as well -# We should thus try to account for both of those dependencies. diff --git a/xdem/spstats.py b/xdem/spstats.py index e30feb22..7e93a691 100644 --- a/xdem/spstats.py +++ b/xdem/spstats.py @@ -1059,7 +1059,7 @@ def plot_1d_binning(df: pd.DataFrame, var_name: str, statistic_name: str, label_ label_statistic = statistic_name # Subsample to 1D and for the variable of interest - df_sub = df[np.logical_and(df.nd == 1, np.isfinite(pd.IntervalIndex(df[var_name]).mid))] + df_sub = df[np.logical_and(df.nd == 1, np.isfinite(pd.IntervalIndex(df[var_name]).mid))].copy() # Remove statistic calculated in bins with too low count df_sub.loc[df_sub['count'] Date: Sun, 4 Jul 2021 16:21:55 +0200 Subject: [PATCH 041/113] fix warnings and improve prints --- examples/plot_nonstationary_error.py | 32 +++++++++++---------- xdem/spstats.py | 42 +++++++++++++++++++--------- 2 files changed, 47 insertions(+), 27 deletions(-) diff --git a/examples/plot_nonstationary_error.py b/examples/plot_nonstationary_error.py index 413cf234..ba80c4e9 100644 --- a/examples/plot_nonstationary_error.py +++ b/examples/plot_nonstationary_error.py @@ -123,8 +123,9 @@ # Here's our simplified relation! We now have both slope and maximum absolute curvature with clear variability of # the elevation measurement error. # -# **But, one might rightfully wonder: high curvatures might occur more often around steep slopes than flat slope, +# **But, one might wonder: high curvatures might occur more often around steep slopes than flat slope, # so what if those two dependencies are actually one and the same?** +# # We need to explore the variability with both slope and curvature at the same time: df = xdem.spstats.nd_binning(values=ddem.data,list_var=[slope, maxc], list_var_names=['slope','maxc'], @@ -134,9 +135,10 @@ xdem.spstats.plot_2d_binning(df, 'slope', 'maxc', 'nmad', 'Slope (degrees)', 'Maximum absolute curvature (100 m$^{-1}$)', 'NMAD of dh (m)') # %% -# We can see that part of the variability seems to be independent, but with uniform bins the distribution across the range -# is not well represented. -# If we use quantiles for both binning variables, and adjust the plot scale: +# We can see that part of the variability seems to be independent, but with the uniform bins it is hard to tell much +# more. +# +# If we use custom quantiles for both binning variables, and adjust the plot scale: custom_bin_slope = np.unique([np.quantile(slope,0.05*i) for i in range(20)] + [np.quantile(slope,0.95+0.02*i) for i in range(2)] @@ -155,18 +157,22 @@ # %% -# We identify that the two variables have an independent effect on the precision: +# We identify clearly that the two variables have an independent effect on the precision, with # -# - *high curvatures and flat slopes* have larger errors than *low curvatures and flat slopes* -# - *steep slopes and low curvatures* have larger errors than *low curvatures and flat slopes* as well +# - *high curvatures and flat slopes* that have larger errors than *low curvatures and flat slopes* +# - *steep slopes and low curvatures* that have larger errors than *low curvatures and flat slopes* as well # -# To later account for the non-stationarities identified, the simplest approach is a numerical approximation i.e. a piecewise -# linear interpolation/extrapolation of the binning results. +# We also identify that, steep slopes (> 40°) only correspond to high curvature, while the opposite is not true, hence +# the importance of mapping the variability in two dimensions. +# +# Now we need to account for the non-stationarities identified. For this, the simplest approach is a numerical +# approximation i.e. a piecewise linear interpolation/extrapolation based on the binning results. # To ensure that only robust statistic values are used in the interpolation, we set a ``min_count`` value at 100 samples. slope_curv_to_dh_err = xdem.spstats.interp_nd_binning(df,list_var_names=['slope','maxc'],statistic='nmad',min_count=100) -# %% The output is an interpolant function of slope and curvature that we can use to estimate the elevation measurement +# %% +# The output is an interpolant function of slope and curvature that we can use to estimate the elevation measurement # error at any point. # # For instance, estimate the elevation measurement error for @@ -176,10 +182,8 @@ # - a slope of 0 degrees and a maximum absolute curvature of 0.05 m\ :sup:`-1`\ , # - a slope of 40 degrees and a maximum absolute curvature of 0.05 m\ :sup:`-1`\ . -print(slope_curv_to_dh_err((0,0))) -print(slope_curv_to_dh_err((40,0))) -print(slope_curv_to_dh_err((0,5))) -print(slope_curv_to_dh_err((40,5))) +for slope, curv in [(0.,1), (40.,1), (0.,10.), (40.,10.)]: + print('Elevation measurement error for slope of {0:.0f} degrees, curvature of {1:.2f} m-1: {2:.1f}'.format(slope,curv/100,slope_curv_to_dh_err((slope,curv)))+ ' meters.') # %% # The same function can be used to estimate the spatial distribution of the elevation measurement error over the area: diff --git a/xdem/spstats.py b/xdem/spstats.py index 7e93a691..e4d05a12 100644 --- a/xdem/spstats.py +++ b/xdem/spstats.py @@ -173,6 +173,11 @@ def nd_binning(values: np.ndarray, list_var: Iterable[np.ndarray], list_var_name values = values.ravel() list_var = [var.ravel() for var in list_var] + # remove no data values + valid_data = np.logical_and.reduce([np.isfinite(values)]+[np.isfinite(var) for var in list_var]) + values = values[valid_data] + list_var = [var[valid_data] for var in list_var] + statistics_name = [f if isinstance(f,str) else f.__name__ for f in statistics] # get binned statistics in 1d: a simple loop is sufficient @@ -1067,7 +1072,7 @@ def plot_1d_binning(df: pd.DataFrame, var_name: str, statistic_name: str, label_ fig = plt.figure() grid = plt.GridSpec(10, 10, wspace=0.5, hspace=0.5) - # First, an axe to plot the sample histogram + # First, an axis to plot the sample histogram ax0 = fig.add_subplot(grid[:3, :]) ax0.set_xticks([]) @@ -1135,10 +1140,12 @@ def plot_2d_binning(df: pd.DataFrame, var_name_1: str, var_name_2: str, statisti # two histograms for the binning variables # + a colored grid to display the statistic calculated on the value of interest # + a legend panel with statistic colormap and nodata color - fig = plt.figure() + + # For some reason the scientific notation displays weirdly for default figure size + fig = plt.figure(figsize=(10,7.5)) grid = plt.GridSpec(10, 10, wspace=0.5, hspace=0.5) - # First, an horizontal axe on top to plot the sample histogram of the first variable + # First, an horizontal axis on top to plot the sample histogram of the first variable ax0 = fig.add_subplot(grid[:3, :-3]) ax0.set_xscale(scale_var_1) ax0.set_xticklabels([]) @@ -1155,8 +1162,11 @@ def plot_2d_binning(df: pd.DataFrame, var_name_1: str, var_name_2: str, statisti ax0.fill_between([df_var1[var_name_1].values[0].left, df_var1[var_name_1].values[0].right], [0] * 2, [count] * 2, facecolor=plt.cm.Greys(0.75), alpha=1, edgecolor='white') ax0.set_ylabel('Sample count') - ax0.set_ylim((0,1.1*np.max(list_counts))) - ax0.set_xlim((np.min(interval_var_1.left),np.max(interval_var_1.right))) + # In case the axis value does not agree with the scale (e.g., 0 for log scale) + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + ax0.set_ylim((0,1.1*np.max(list_counts))) + ax0.set_xlim((np.min(interval_var_1.left),np.max(interval_var_1.right))) ax0.ticklabel_format(axis='y',style='sci',scilimits=(0,0)) ax0.spines['top'].set_visible(False) ax0.spines['right'].set_visible(False) @@ -1165,7 +1175,7 @@ def plot_2d_binning(df: pd.DataFrame, var_name_1: str, var_name_2: str, statisti ax0.text(0.5, 0.5, "Fixed number of\nsamples: " + '{:,}'.format(int(list_counts[0])), ha='center', va='center', fontweight='bold', transform=ax0.transAxes, bbox=dict(facecolor='white', alpha=0.8)) - # Second, a vertical axe on the right to plot the sample histogram of the second variable + # Second, a vertical axis on the right to plot the sample histogram of the second variable ax1 = fig.add_subplot(grid[3:, -3:]) ax1.set_yscale(scale_var_2) ax1.set_yticklabels([]) @@ -1182,8 +1192,11 @@ def plot_2d_binning(df: pd.DataFrame, var_name_1: str, var_name_2: str, statisti ax1.fill_between([0, count], [df_var2[var_name_2].values[0].left] * 2, [df_var2[var_name_2].values[0].right] * 2, facecolor=plt.cm.Greys(0.75), alpha=1, edgecolor='white') ax1.set_xlabel('Sample count') - ax1.set_xlim((0,1.1*np.max(list_counts))) - ax1.set_ylim((np.min(interval_var_2.left),np.max(interval_var_2.right))) + # In case the axis value does not agree with the scale (e.g., 0 for log scale) + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + ax1.set_xlim((0,1.1*np.max(list_counts))) + ax1.set_ylim((np.min(interval_var_2.left),np.max(interval_var_2.right))) ax1.ticklabel_format(axis='x',style='sci',scilimits=(0,0)) ax1.spines['top'].set_visible(False) ax1.spines['right'].set_visible(False) @@ -1192,7 +1205,7 @@ def plot_2d_binning(df: pd.DataFrame, var_name_1: str, var_name_2: str, statisti ax1.text(0.5, 0.5, "Fixed number of\nsamples: " + '{:,}'.format(int(list_counts[0])), ha='center', va='center', fontweight='bold', transform=ax1.transAxes, rotation=90, bbox=dict(facecolor='white', alpha=0.8)) - # Third, an axe to plot the data as a colored grid + # Third, an axis to plot the data as a colored grid ax = fig.add_subplot(grid[3:, :-3]) # Define limits of colormap is none are provided, robust max and min using percentiles @@ -1228,8 +1241,11 @@ def plot_2d_binning(df: pd.DataFrame, var_name_1: str, var_name_2: str, statisti ax.set_ylabel(label_var_name_2) ax.set_xscale(scale_var_1) ax.set_yscale(scale_var_2) - ax.set_xlim((np.min(interval_var_1.left),np.max(interval_var_1.right))) - ax.set_ylim((np.min(interval_var_2.left),np.max(interval_var_2.right))) + # In case the axis value does not agree with the scale (e.g., 0 for log scale) + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + ax.set_xlim((np.min(interval_var_1.left),np.max(interval_var_1.right))) + ax.set_ylim((np.min(interval_var_2.left),np.max(interval_var_2.right))) # Fourth and finally, add a colormap and nodata color to the legend axcmap = fig.add_subplot(grid[:3, -3:]) @@ -1242,7 +1258,7 @@ def plot_2d_binning(df: pd.DataFrame, var_name_1: str, var_name_2: str, statisti axcmap.spines['right'].set_visible(False) axcmap.spines['bottom'].set_visible(False) - # Create an inset axe to manage the scale of the colormap + # Create an inset axis to manage the scale of the colormap cbaxes = axcmap.inset_axes([0, 0.75, 1, 0.2], label='cmap') # Create colormap object and plot @@ -1253,7 +1269,7 @@ def plot_2d_binning(df: pd.DataFrame, var_name_1: str, var_name_2: str, statisti cb.ax.tick_params(width=0.5, length=2) cb.set_label(label_statistic) - # Create an inset axe to manage the scale of the nodata legend + # Create an inset axis to manage the scale of the nodata legend nodata = axcmap.inset_axes([0.4, 0.1, 0.2, 0.2], label='nodata') # Plot a nodata legend From feecde811237a37de9a35d17329753481889f646 Mon Sep 17 00:00:00 2001 From: rhugonne Date: Sun, 4 Jul 2021 16:34:31 +0200 Subject: [PATCH 042/113] fix curv unit --- examples/plot_nonstationary_error.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/examples/plot_nonstationary_error.py b/examples/plot_nonstationary_error.py index ba80c4e9..338c2f76 100644 --- a/examples/plot_nonstationary_error.py +++ b/examples/plot_nonstationary_error.py @@ -182,8 +182,9 @@ # - a slope of 0 degrees and a maximum absolute curvature of 0.05 m\ :sup:`-1`\ , # - a slope of 40 degrees and a maximum absolute curvature of 0.05 m\ :sup:`-1`\ . -for slope, curv in [(0.,1), (40.,1), (0.,10.), (40.,10.)]: - print('Elevation measurement error for slope of {0:.0f} degrees, curvature of {1:.2f} m-1: {2:.1f}'.format(slope,curv/100,slope_curv_to_dh_err((slope,curv)))+ ' meters.') +for slope, curv in [(0.,0.1), (50.,0.1), (0.,20.), (50.,20.)]: + print('Elevation measurement error for slope of {0:.0f} degrees, ' + 'curvature of {1:.2f} m-1: {2:.1f}'.format(slope,curv/100,slope_curv_to_dh_err((slope,curv)))+ ' meters.') # %% # The same function can be used to estimate the spatial distribution of the elevation measurement error over the area: From 54890e1cace88b6ece85aca52243d30ae3c5936b Mon Sep 17 00:00:00 2001 From: rhugonne Date: Sun, 4 Jul 2021 16:39:13 +0200 Subject: [PATCH 043/113] fix min_count --- examples/plot_nonstationary_error.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/plot_nonstationary_error.py b/examples/plot_nonstationary_error.py index 338c2f76..0351d55a 100644 --- a/examples/plot_nonstationary_error.py +++ b/examples/plot_nonstationary_error.py @@ -167,9 +167,9 @@ # # Now we need to account for the non-stationarities identified. For this, the simplest approach is a numerical # approximation i.e. a piecewise linear interpolation/extrapolation based on the binning results. -# To ensure that only robust statistic values are used in the interpolation, we set a ``min_count`` value at 100 samples. +# To ensure that only robust statistic values are used in the interpolation, we set a ``min_count`` value at 30 samples. -slope_curv_to_dh_err = xdem.spstats.interp_nd_binning(df,list_var_names=['slope','maxc'],statistic='nmad',min_count=100) +slope_curv_to_dh_err = xdem.spstats.interp_nd_binning(df,list_var_names=['slope','maxc'],statistic='nmad',min_count=30) # %% # The output is an interpolant function of slope and curvature that we can use to estimate the elevation measurement @@ -197,7 +197,7 @@ ref_dem.bounds.bottom, ref_dem.bounds.top, ] -plt.imshow(dh_err.squeeze(), cmap="Reds", vmin=2, vmax=6, extent=plt_extent) +plt.imshow(dh_err.squeeze(), cmap="Reds", vmin=2, vmax=8, extent=plt_extent) cbar = plt.colorbar() cbar.set_label('Elevation measurement error (m)') plt.show() From fc5920ba715d1616a5398ed431f7116b516a66dc Mon Sep 17 00:00:00 2001 From: rhugonne Date: Sun, 4 Jul 2021 16:58:43 +0200 Subject: [PATCH 044/113] fix slope variable name --- examples/plot_nonstationary_error.py | 8 ++++---- xdem/spstats.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/examples/plot_nonstationary_error.py b/examples/plot_nonstationary_error.py index 0351d55a..0bace361 100644 --- a/examples/plot_nonstationary_error.py +++ b/examples/plot_nonstationary_error.py @@ -19,7 +19,7 @@ **Reference**: `Hugonnet et al. (2021) `_, applied to the terrain slope and quality of stereo-correlation (Equation 1, Extended Data Fig. 3a). """ -# sphinx_gallery_thumbnail_number = 10 +# sphinx_gallery_thumbnail_number = 8 import matplotlib.pyplot as plt import numpy as np import xdem @@ -182,13 +182,13 @@ # - a slope of 0 degrees and a maximum absolute curvature of 0.05 m\ :sup:`-1`\ , # - a slope of 40 degrees and a maximum absolute curvature of 0.05 m\ :sup:`-1`\ . -for slope, curv in [(0.,0.1), (50.,0.1), (0.,20.), (50.,20.)]: +for s, c in [(0.,0.1), (50.,0.1), (0.,20.), (50.,20.)]: print('Elevation measurement error for slope of {0:.0f} degrees, ' - 'curvature of {1:.2f} m-1: {2:.1f}'.format(slope,curv/100,slope_curv_to_dh_err((slope,curv)))+ ' meters.') + 'curvature of {1:.2f} m-1: {2:.1f}'.format(s, c/100, slope_curv_to_dh_err((s,c)))+ ' meters.') # %% # The same function can be used to estimate the spatial distribution of the elevation measurement error over the area: -dh_err = slope_curv_to_dh_err((slope,maxc)) +dh_err = slope_curv_to_dh_err((slope, maxc)) plt.figure(figsize=(8, 5)) plt_extent = [ diff --git a/xdem/spstats.py b/xdem/spstats.py index e4d05a12..de5d6d6d 100644 --- a/xdem/spstats.py +++ b/xdem/spstats.py @@ -1142,7 +1142,7 @@ def plot_2d_binning(df: pd.DataFrame, var_name_1: str, var_name_2: str, statisti # + a legend panel with statistic colormap and nodata color # For some reason the scientific notation displays weirdly for default figure size - fig = plt.figure(figsize=(10,7.5)) + fig = plt.figure(figsize=(8,6)) grid = plt.GridSpec(10, 10, wspace=0.5, hspace=0.5) # First, an horizontal axis on top to plot the sample histogram of the first variable From eeffc0eed752f6634dcfb4dc0264b49e08ae6b1a Mon Sep 17 00:00:00 2001 From: rhugonne Date: Sun, 4 Jul 2021 17:23:42 +0200 Subject: [PATCH 045/113] shorten title --- examples/plot_nonstationary_error.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/plot_nonstationary_error.py b/examples/plot_nonstationary_error.py index 0bace361..88bde983 100644 --- a/examples/plot_nonstationary_error.py +++ b/examples/plot_nonstationary_error.py @@ -1,6 +1,6 @@ """ -Non-stationarities in elevation measurement errors -================================================== +Non-stationarity of elevation measurement errors +================================================ Digital elevation models have a precision that can vary with terrain and instrument-related variables. However, quantifying this precision is complex and non-stationarities, i.e. variability of the measurement error, has rarely been From d3cabedba3d84a048b3a1a98fc960a58c320c362 Mon Sep 17 00:00:00 2001 From: rhugonne Date: Sun, 4 Jul 2021 17:52:27 +0200 Subject: [PATCH 046/113] reformulation --- examples/plot_nonstationary_error.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/plot_nonstationary_error.py b/examples/plot_nonstationary_error.py index 88bde983..024a0b31 100644 --- a/examples/plot_nonstationary_error.py +++ b/examples/plot_nonstationary_error.py @@ -4,7 +4,7 @@ Digital elevation models have a precision that can vary with terrain and instrument-related variables. However, quantifying this precision is complex and non-stationarities, i.e. variability of the measurement error, has rarely been -accounted for continuously with some studies using arbitrary thresholds on the slope or other variables (see :ref:`intro`). +accounted for, with only some studies that used arbitrary filtering thresholds on the slope or other variables (see :ref:`intro`). Quantifying the non-stationarities in elevation measurement errors is essential to use stable terrain as a proxy for assessing the precision on other types of terrain (Hugonnet et al., in prep) and allows to standardize the measurement From 88aa7ad7496ad92286c869d5fc74008aaedf49f9 Mon Sep 17 00:00:00 2001 From: rhugonne Date: Sun, 4 Jul 2021 18:02:49 +0200 Subject: [PATCH 047/113] text fixes --- examples/plot_nonstationary_error.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/examples/plot_nonstationary_error.py b/examples/plot_nonstationary_error.py index 024a0b31..bd022b37 100644 --- a/examples/plot_nonstationary_error.py +++ b/examples/plot_nonstationary_error.py @@ -26,8 +26,8 @@ import geoutils as gu # %% -# We start by loading example files of a difference of DEMs at Longyearbyen glacier, the reference DEM to later derive -# terrain attribute, and the outlines to rasterize a glacier mask. +# We start by loading example files including a difference of DEMs at Longyearbyen glacier, the reference DEM later used to derive +# several terrain attributes, and the outlines to rasterize a glacier mask. # Prior to differencing, the DEMs were aligned using :class:`xdem.coreg.NuthKaab` as shown in # the :ref:`sphx_glr_auto_examples_plot_nuth_kaab.py` example. We later refer to those elevation differences as *dh*. @@ -74,18 +74,20 @@ # In statistical terms, such a variability of `variance `_ is referred as # `heteroscedasticity `_. Here we observe heteroscedastic elevation # differences due to a non-stationarity of variance with the terrain slope. +# # What about the aspect? xdem.spstats.plot_1d_binning(df, 'aspect', 'nmad', 'Aspect (degrees)', 'NMAD of dh (m)') # %% # There is no variability with the aspect which shows a dispersion averaging 2-3 meters, i.e. that of the complete sample. -# And what about the profile curvature? +# +# What about the plan curvature? xdem.spstats.plot_1d_binning(df, 'planc', 'nmad', 'Planform curvature (100 m$^{-1}$)', 'NMAD of dh (m)') # %% -# The relation with the profile curvature remains ambiguous. +# The relation with the plan curvature remains ambiguous. # We should better define our bins to avoid sampling bins with too many or too few samples. For this, we can partition # the data in quantiles in :func:`xdem.spstats.nd_binning`. # Note: we need a higher number of bins to work with quantiles and still resolve the edges of the distribution. Thus, as From 749cea54c4a2dadacfba196afaf89decc3661056 Mon Sep 17 00:00:00 2001 From: rhugonne Date: Sun, 4 Jul 2021 19:14:41 +0200 Subject: [PATCH 048/113] fix text --- examples/plot_nonstationary_error.py | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/examples/plot_nonstationary_error.py b/examples/plot_nonstationary_error.py index bd022b37..c7bb2dac 100644 --- a/examples/plot_nonstationary_error.py +++ b/examples/plot_nonstationary_error.py @@ -93,21 +93,21 @@ # Note: we need a higher number of bins to work with quantiles and still resolve the edges of the distribution. Thus, as # with many dimensions the N dimensional bin size increases exponentially, we avoid binning all variables at the same # time and instead bin one at a time. -# We define 200 quantile bins of size 0.005 (equivalent to 0.5% percentile bins) for the profile curvature: +# We define 1000 quantile bins of size 0.001 (equivalent to 0.1% percentile bins) for the profile curvature: df = xdem.spstats.nd_binning(values=ddem.data,list_var=[profc], list_var_names=['profc'], statistics=['count',np.nanmedian,xdem.spstats.nmad], - list_var_bins=[[np.nanquantile(profc,0.005*i) for i in range(201)]]) + list_var_bins=[[np.nanquantile(profc,0.001*i) for i in range(1001)]]) xdem.spstats.plot_1d_binning(df, 'profc', 'nmad', 'Profile curvature (100 m$^{-1}$)', 'NMAD of dh (m)') # %% -# We now clearly identify the variability with the profile curvature, from 2 meters for low curvatures to above 6 meters +# We now clearly identify the variability with the profile curvature, from 2 meters for low curvatures to above 4 meters # for higher positive or negative curvature. # What about the role of the plan curvature? df = xdem.spstats.nd_binning(values=ddem.data,list_var=[planc], list_var_names=['planc'], statistics=['count',np.nanmedian,xdem.spstats.nmad], - list_var_bins=[[np.nanquantile(planc,0.005*i) for i in range(201)]]) + list_var_bins=[[np.nanquantile(planc,0.001*i) for i in range(1001)]]) xdem.spstats.plot_1d_binning(df, 'planc', 'nmad', 'Planform curvature (100 m$^{-1}$)', 'NMAD of dh (m)') # %% @@ -177,12 +177,7 @@ # The output is an interpolant function of slope and curvature that we can use to estimate the elevation measurement # error at any point. # -# For instance, estimate the elevation measurement error for -# -# - a slope of 0 degrees and a maximum absolute curvature of 0 m\ :sup:`-1`\ , -# - a slope of 40 degrees and a maximum absolute curvature of 0 m\ :sup:`-1`\ , -# - a slope of 0 degrees and a maximum absolute curvature of 0.05 m\ :sup:`-1`\ , -# - a slope of 40 degrees and a maximum absolute curvature of 0.05 m\ :sup:`-1`\ . +# For instance, estimate the elevation measurement error for different points: for s, c in [(0.,0.1), (50.,0.1), (0.,20.), (50.,20.)]: print('Elevation measurement error for slope of {0:.0f} degrees, ' From ee12cd26b42cae6f1ee7206f90ef63b6a2ea1fbc Mon Sep 17 00:00:00 2001 From: rhugonne Date: Sun, 4 Jul 2021 19:43:28 +0200 Subject: [PATCH 049/113] fix text --- examples/plot_nonstationary_error.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/examples/plot_nonstationary_error.py b/examples/plot_nonstationary_error.py index c7bb2dac..e6f99e7b 100644 --- a/examples/plot_nonstationary_error.py +++ b/examples/plot_nonstationary_error.py @@ -31,20 +31,20 @@ # Prior to differencing, the DEMs were aligned using :class:`xdem.coreg.NuthKaab` as shown in # the :ref:`sphx_glr_auto_examples_plot_nuth_kaab.py` example. We later refer to those elevation differences as *dh*. -# We load the data ref_dem = xdem.DEM(xdem.examples.get_path("longyearbyen_ref_dem")) ddem = xdem.DEM(xdem.examples.get_path("longyearbyen_ddem")) glacier_outlines = gu.Vector(xdem.examples.get_path("longyearbyen_glacier_outlines")) -# Rasterize a glacier mask mask_glacier = glacier_outlines.create_mask(ddem) -# Remove values on unstable terrain + +# %% +# We remove values on unstable terrain ddem.data[mask_glacier] = np.nan # %% -# We use the reference DEM to derive terrain variables such as slope, aspect, curvature that we'll use to explore potential -# non-stationarities in elevation measurement error. +# We use the reference DEM to derive terrain variables such as slope, aspect, curvature (see :ref:`sphx_glr_auto_examples_plot_terrain_attributes`) +# that we'll use to explore potential non-stationarities in elevation measurement error -# We compute the slope, aspect, and both plan and profile curvatures +# We compute the slope, aspect, and both plan and profile curvatures: slope, aspect, planc, profc = \ xdem.terrain.get_terrain_attribute(dem=ref_dem.data, attribute=['slope','aspect', 'planform_curvature', 'profile_curvature'], @@ -177,7 +177,7 @@ # The output is an interpolant function of slope and curvature that we can use to estimate the elevation measurement # error at any point. # -# For instance, estimate the elevation measurement error for different points: +# For instance: for s, c in [(0.,0.1), (50.,0.1), (0.,20.), (50.,20.)]: print('Elevation measurement error for slope of {0:.0f} degrees, ' From a5ad477321f9247f2afa570224b76d36baaf6274 Mon Sep 17 00:00:00 2001 From: rhugonne Date: Tue, 6 Jul 2021 15:22:45 +0200 Subject: [PATCH 050/113] use subsample_raster, simplify with skgstat count property --- examples/plot_vgm_error.py | 244 +++++++++++++++++++++++++++++++++++++ xdem/spstats.py | 67 +++------- 2 files changed, 263 insertions(+), 48 deletions(-) create mode 100644 examples/plot_vgm_error.py diff --git a/examples/plot_vgm_error.py b/examples/plot_vgm_error.py new file mode 100644 index 00000000..e9b0cefd --- /dev/null +++ b/examples/plot_vgm_error.py @@ -0,0 +1,244 @@ +""" +Spatial correlation of elevation measurement errors +=================================================== + +Digital elevation models have elevation measurement errors that can vary with terrain or instrument-related variables +(see :ref:`sphx_glr_auto_examples_plot_nonstationary_error.py`), but those measurement errors are also often +`spatially correlated `_. +While many DEM studies have been using short-range `variogram `_ models to +estimate the correlation of elevation measurement errors (e.g., `Howat et al. (2008) `_ +, `Wang and Kääb (2015) `_), recent studies show that variograms of multiple ranges +provide more realistic estimates of spatial correlation for many DEMs (e.g., `Dehecq et al. (2020) `_ +, `Hugonnet et al. (2021) `_). + +Quantifying the spatial correlation in elevation measurement errors is essential to integrate measurement errors over +an area of interest (e.g, to estimate the error of a mean or sum of samples). Once the spatial correlations are quantified, + several methods exist the approximate the measurement error in space (`Rolstad et al. (2009) `_ + , Hugonnet et al. (in prep)). Further details are availale in :ref:`spatialstats`. + +Here, we show an example in which we estimate spatially integrated elevation measurement errors for a DEM difference of +Longyearbyen glacier. We first quantify the spatial correlations using :func:`xdem.spstats.sample_multirange_empirical_variogram` +based on routines of `scikit-gstat `_. We then model the empirical variogram + using a sum of variogram models using :func:`xdem.spstats.fit_model_sum_vgm`. +Finally, we integrate the variogram models for varying surface areas to estimate the spatially integrated elevation +measurement errors for this DEM difference. + +""" +# sphinx_gallery_thumbnail_number = 8 +import matplotlib.pyplot as plt +import numpy as np +import xdem +import geoutils as gu + +# %% +# We start by loading example files including a difference of DEMs at Longyearbyen glacier, the reference DEM later used to derive +# several terrain attributes, and the outlines to rasterize a glacier mask. +# Prior to differencing, the DEMs were aligned using :class:`xdem.coreg.NuthKaab` as shown in +# the :ref:`sphx_glr_auto_examples_plot_nuth_kaab.py` example. We later refer to those elevation differences as *dh*. + +ddem = xdem.DEM(xdem.examples.get_path("longyearbyen_ddem")) +glacier_outlines = gu.Vector(xdem.examples.get_path("longyearbyen_glacier_outlines")) +mask_glacier = glacier_outlines.create_mask(ddem) + +# %% +# We remove values on glacier terrain +ddem.data[mask_glacier] = np.nan + +# %% +# Let's plot the elevation differences +plt.figure(figsize=(8, 5)) +plt_extent = [ + ddem.bounds.left, + ddem.bounds.right, + ddem.bounds.bottom, + ddem.bounds.top, +] +plt.imshow(ddem.data.squeeze(), cmap="RdYlBu", vmin=-4, vmax=4, extent=plt_extent) +cbar = plt.colorbar() +cbar.set_label('Elevation differences (m)') +plt.show() + + +# %% +# We can see that the elevation difference is still polluted by unmasked glaciers, let's filter large outliers outside 4 NMAD +ddem.data[np.abs(ddem.data)>4*xdem.spstats.nmad(ddem.data)] = np.nan + +# %% +# Let's plot the elevation differences after filtering +plt.figure(figsize=(8, 5)) +plt_extent = [ + ddem.bounds.left, + ddem.bounds.right, + ddem.bounds.bottom, + ddem.bounds.top, +] +plt.imshow(ddem.data.squeeze(), cmap="RdYlBu", vmin=-4, vmax=4, extent=plt_extent) +cbar = plt.colorbar() +cbar.set_label('Elevation differences (m)') +plt.show() + +# %% +np.random.seed(42) + +# Sample empirical variogram +df = xdem.spstats.sample_multirange_empirical_variogram(dh=ddem.data, gsd=ddem.res[0], nsamp=2000, nrun=100, maxlag=20000) + +# Plot empirical variogram +xdem.spstats.plot_vgm(df) +fun, _ = xdem.spstats.fit_model_sum_vgm(['Sph'], df) +fun2, _ = xdem.spstats.fit_model_sum_vgm(['Sph', 'Sph', 'Sph'], emp_vgm_df=df) + + +# %% +# We use :func:`xdem.spstats.nd_binning` to perform N-dimensional binning on all those terrain variables, with uniform +# bin length divided by 30. We use the :ref:`spatial_stats_nmad` as a robust measure of `statistical dispersion `_. + +df = xdem.spstats.nd_binning(values=ddem.data,list_var=[slope, aspect, planc, profc], + list_var_names=['slope','aspect','planc','profc'], + statistics=['count',np.nanmedian,xdem.spstats.nmad], + list_var_bins=30) + +# %% +# We obtain a dataframe with the 1D binning results for each variable, the 2D binning results for all combinations of +# variables and the N-D (here 4D) binning with all variables. +# We can now visualize the results of the 1D binning of the computed NMAD of elevation differences with each variable +# using :func:`xdem.spstats.plot_1d_binning`. +# We can start with the slope that has been long known to be related to the elevation measurement error (e.g., +# `Toutin (2002) `_). +xdem.spstats.plot_1d_binning(df, 'slope', 'nmad', 'Slope (degrees)', 'NMAD of dh (m)') + +# %% +# We identify a clear variability, with the dispersion estimated from the NMAD increasing from ~2 meters for nearly flat +# slopes to above 12 meters for slopes steeper than 50°. +# In statistical terms, such a variability of `variance `_ is referred as +# `heteroscedasticity `_. Here we observe heteroscedastic elevation +# differences due to a non-stationarity of variance with the terrain slope. +# +# What about the aspect? + +xdem.spstats.plot_1d_binning(df, 'aspect', 'nmad', 'Aspect (degrees)', 'NMAD of dh (m)') + +# %% +# There is no variability with the aspect which shows a dispersion averaging 2-3 meters, i.e. that of the complete sample. +# +# What about the plan curvature? + +xdem.spstats.plot_1d_binning(df, 'planc', 'nmad', 'Planform curvature (100 m$^{-1}$)', 'NMAD of dh (m)') + +# %% +# The relation with the plan curvature remains ambiguous. +# We should better define our bins to avoid sampling bins with too many or too few samples. For this, we can partition +# the data in quantiles in :func:`xdem.spstats.nd_binning`. +# Note: we need a higher number of bins to work with quantiles and still resolve the edges of the distribution. Thus, as +# with many dimensions the N dimensional bin size increases exponentially, we avoid binning all variables at the same +# time and instead bin one at a time. +# We define 1000 quantile bins of size 0.001 (equivalent to 0.1% percentile bins) for the profile curvature: + +df = xdem.spstats.nd_binning(values=ddem.data,list_var=[profc], list_var_names=['profc'], + statistics=['count',np.nanmedian,xdem.spstats.nmad], + list_var_bins=[[np.nanquantile(profc,0.001*i) for i in range(1001)]]) +xdem.spstats.plot_1d_binning(df, 'profc', 'nmad', 'Profile curvature (100 m$^{-1}$)', 'NMAD of dh (m)') + +# %% +# We now clearly identify the variability with the profile curvature, from 2 meters for low curvatures to above 4 meters +# for higher positive or negative curvature. +# What about the role of the plan curvature? + +df = xdem.spstats.nd_binning(values=ddem.data,list_var=[planc], list_var_names=['planc'], + statistics=['count',np.nanmedian,xdem.spstats.nmad], + list_var_bins=[[np.nanquantile(planc,0.001*i) for i in range(1001)]]) +xdem.spstats.plot_1d_binning(df, 'planc', 'nmad', 'Planform curvature (100 m$^{-1}$)', 'NMAD of dh (m)') + +# %% +# The plan curvature shows a similar relation. Those are symmetrical with 0, and almost equal for both types of curvature. +# To simplify the analysis, we here combine those curvatures into the maximum absolute curvature: + +# Derive maximum absolute curvature +maxc = np.maximum(np.abs(planc),np.abs(profc)) +df = xdem.spstats.nd_binning(values=ddem.data,list_var=[maxc], list_var_names=['maxc'], + statistics=['count',np.nanmedian,xdem.spstats.nmad], + list_var_bins=[[np.nanquantile(maxc,0.002*i) for i in range(501)]]) +xdem.spstats.plot_1d_binning(df, 'maxc', 'nmad', 'Maximum absolute curvature (100 m$^{-1}$)', 'NMAD of dh (m)') + +# %% +# Here's our simplified relation! We now have both slope and maximum absolute curvature with clear variability of +# the elevation measurement error. +# +# **But, one might wonder: high curvatures might occur more often around steep slopes than flat slope, +# so what if those two dependencies are actually one and the same?** +# +# We need to explore the variability with both slope and curvature at the same time: + +df = xdem.spstats.nd_binning(values=ddem.data,list_var=[slope, maxc], list_var_names=['slope','maxc'], + statistics=['count',np.nanmedian,xdem.spstats.nmad], + list_var_bins=30) + +xdem.spstats.plot_2d_binning(df, 'slope', 'maxc', 'nmad', 'Slope (degrees)', 'Maximum absolute curvature (100 m$^{-1}$)', 'NMAD of dh (m)') + +# %% +# We can see that part of the variability seems to be independent, but with the uniform bins it is hard to tell much +# more. +# +# If we use custom quantiles for both binning variables, and adjust the plot scale: + +custom_bin_slope = np.unique([np.quantile(slope,0.05*i) for i in range(20)] + + [np.quantile(slope,0.95+0.02*i) for i in range(2)] + + [np.quantile(slope,0.98 + 0.005*i) for i in range(3)] + + [np.quantile(slope,0.995 + 0.001*i) for i in range(6)]) + +custom_bin_curvature = np.unique([np.quantile(maxc,0.05*i) for i in range(20)] + + [np.quantile(maxc,0.95+0.02*i) for i in range(2)] + + [np.quantile(maxc,0.98 + 0.005*i) for i in range(3)] + + [np.quantile(maxc,0.995 + 0.001*i) for i in range(6)]) + +df = xdem.spstats.nd_binning(values=ddem.data,list_var=[slope, maxc], list_var_names=['slope','maxc'], + statistics=['count',np.nanmedian,xdem.spstats.nmad], + list_var_bins=[custom_bin_slope,custom_bin_curvature]) +xdem.spstats.plot_2d_binning(df, 'slope', 'maxc', 'nmad', 'Slope (degrees)', 'Maximum absolute curvature (100 m$^{-1}$)', 'NMAD of dh (m)', scale_var_2='log', vmin=2, vmax=10) + + +# %% +# We identify clearly that the two variables have an independent effect on the precision, with +# +# - *high curvatures and flat slopes* that have larger errors than *low curvatures and flat slopes* +# - *steep slopes and low curvatures* that have larger errors than *low curvatures and flat slopes* as well +# +# We also identify that, steep slopes (> 40°) only correspond to high curvature, while the opposite is not true, hence +# the importance of mapping the variability in two dimensions. +# +# Now we need to account for the non-stationarities identified. For this, the simplest approach is a numerical +# approximation i.e. a piecewise linear interpolation/extrapolation based on the binning results. +# To ensure that only robust statistic values are used in the interpolation, we set a ``min_count`` value at 30 samples. + +slope_curv_to_dh_err = xdem.spstats.interp_nd_binning(df,list_var_names=['slope','maxc'],statistic='nmad',min_count=30) + +# %% +# The output is an interpolant function of slope and curvature that we can use to estimate the elevation measurement +# error at any point. +# +# For instance: + +for s, c in [(0.,0.1), (50.,0.1), (0.,20.), (50.,20.)]: + print('Elevation measurement error for slope of {0:.0f} degrees, ' + 'curvature of {1:.2f} m-1: {2:.1f}'.format(s, c/100, slope_curv_to_dh_err((s,c)))+ ' meters.') + +# %% +# The same function can be used to estimate the spatial distribution of the elevation measurement error over the area: +dh_err = slope_curv_to_dh_err((slope, maxc)) + +plt.figure(figsize=(8, 5)) +plt_extent = [ + ref_dem.bounds.left, + ref_dem.bounds.right, + ref_dem.bounds.bottom, + ref_dem.bounds.top, +] +plt.imshow(dh_err.squeeze(), cmap="Reds", vmin=2, vmax=8, extent=plt_extent) +cbar = plt.colorbar() +cbar.set_label('Elevation measurement error (m)') +plt.show() + + + + + diff --git a/xdem/spstats.py b/xdem/spstats.py index de5d6d6d..26d0cb99 100644 --- a/xdem/spstats.py +++ b/xdem/spstats.py @@ -21,7 +21,7 @@ from skimage.draw import disk from scipy.interpolate import RegularGridInterpolator, LinearNDInterpolator, griddata from scipy.stats import binned_statistic, binned_statistic_2d, binned_statistic_dd -from xdem.spatial_tools import nmad +from xdem.spatial_tools import nmad, subsample_raster with warnings.catch_warnings(): warnings.filterwarnings("ignore", category=DeprecationWarning) @@ -258,24 +258,10 @@ def get_empirical_variogram(dh: np.ndarray, coords: np.ndarray, **kwargs) -> pd. :return: empirical variogram (variance, lags, counts) """ - # deriving empirical variogram variance, bin, and count - try: - V = skg.Variogram(coordinates=coords, values=dh, normalize=False, **kwargs) - # return V.to_DataFrame() - - exp = V.experimental - bins = V.bins - count = np.zeros(V.n_lags) - tmp_count = np.fromiter((g.size for g in V.lag_classes()), dtype=int) - count[0:len(tmp_count)] = tmp_count - - # there are still some exceptions not well handled by skgstat - except ZeroDivisionError: - n_lags = kwargs.get('n_lags') or 10 - exp, bins, count = (np.zeros(n_lags)*np.nan for i in range(3)) + V = skg.Variogram(coordinates=coords, values=dh, normalize=False, **kwargs) df = pd.DataFrame() - df = df.assign(exp=exp, bins=bins, count=count) + df = df.assign(exp=V.experimental, bins=V.bins, count=V.count) return df @@ -294,28 +280,6 @@ def wrapper_get_empirical_variogram(argdict: dict, **kwargs) -> pd.DataFrame: return get_empirical_variogram(dh=argdict['dh'], coords=argdict['coords'], **kwargs) -def random_subset(dh: np.ndarray, coords: np.ndarray, nsamp: int) -> tuple[Union[np.ndarray, Any], Union[np.ndarray, Any]]: - - """ - Subsampling of elevation differences with random coordinates - - :param dh: elevation differences - :param coords: coordinates - :param nsamp: number of sammples for subsampling - - :return: subsets of dh and coords - """ - if len(coords) > nsamp: - # TODO: maybe we can also introduce something to sample without replacement between all samples? - subset = np.random.choice(len(coords), nsamp, replace=False) - coords_sub = coords[subset] - dh_sub = dh[subset] - else: - coords_sub = coords - dh_sub = dh - - return dh_sub, coords_sub - def create_circular_mask(shape: Union[int, Sequence[int]], center: Optional[list[float]] = None, radius: Optional[float] = None) -> np.ndarray: """ Create circular mask on a raster, defaults to the center of the array and it's half width @@ -396,7 +360,7 @@ def ring_subset(dh: np.ndarray, coords: np.ndarray, inside_radius: float = 0, ou def sample_multirange_empirical_variogram(dh: np.ndarray, gsd: float = None, coords: np.ndarray = None, - nsamp: int = 10000, range_list: list = None, nrun: int = 1, nproc: int = 1, + subsample: int = 10000, range_list: list = None, nrun: int = 1, nproc: int = 1, **kwargs) -> pd.DataFrame: """ Wrapper to sample multi-range empirical variograms from the data. @@ -407,7 +371,7 @@ def sample_multirange_empirical_variogram(dh: np.ndarray, gsd: float = None, coo :param gsd: ground sampling distance (if array is 2D on structured grid) :param coords: coordinates, to be used only with a flattened elevation differences array and passed as an array of \the pairs of coordinates: one dimension equal to two and the other to that of the flattened elevation differences :param range_list: successive ranges with even binning - :param nsamp: number of samples to randomly draw from the elevation differences + :param subsample: number of samples to randomly draw from the elevation differences :param nrun: number of samplings :param nproc: number of processing cores @@ -433,6 +397,10 @@ def sample_multirange_empirical_variogram(dh: np.ndarray, gsd: float = None, coo coords = np.dstack((x.flatten(), y.flatten())).squeeze() dh = dh.flatten() + valid_data = np.isfinite(dh) + dh = dh[valid_data] + coords = coords[valid_data, :] + # COMMENTING: custom binning is not supported by skgstat yet... # if no range list is specified, define a default one based on the spatial extent of the data and its resolution # if 'bin_func' not in kwargs.keys(): @@ -465,13 +433,13 @@ def sample_multirange_empirical_variogram(dh: np.ndarray, gsd: float = None, coo if 'n_lags' not in kwargs.keys(): kwargs.update({'n_lags': 100}) - # estimate variogram + # estimate variogram for multiple runs if nrun == 1: # subsetting - dh_sub, coords_sub = random_subset(dh, coords, nsamp) + index = subsample_raster(dh, subsample=subsample, return_indices=True) + dh_sub = dh[index] + coords_sub = coords[index, :] # getting empirical variogram - print(dh_sub.shape) - print(coords_sub.shape) df = get_empirical_variogram(dh=dh_sub, coords=coords_sub, **kwargs) df['exp_sigma'] = np.nan @@ -488,12 +456,13 @@ def sample_multirange_empirical_variogram(dh: np.ndarray, gsd: float = None, coo if 'maxlag' not in kwargs.keys(): kwargs.update({'maxlag': max_range}) - # TODO: somewhere here we could think of adding random sampling without replacement if nproc == 1: print('Using 1 core...') list_df_nb = [] for i in range(nrun): - dh_sub, coords_sub = random_subset(dh, coords, nsamp) + index = subsample_raster(dh, subsample=subsample, return_indices=True) + dh_sub = dh[index] + coords_sub = coords[index, :] df = get_empirical_variogram(dh=dh_sub, coords=coords_sub, **kwargs) df['run'] = i list_df_nb.append(df) @@ -502,7 +471,9 @@ def sample_multirange_empirical_variogram(dh: np.ndarray, gsd: float = None, coo list_dh_sub = [] list_coords_sub = [] for i in range(nrun): - dh_sub, coords_sub = random_subset(dh, coords, nsamp) + index = subsample_raster(dh, subsample=subsample, return_indices=True) + dh_sub = dh[index] + coords_sub = coords[index, :] list_dh_sub.append(dh_sub) list_coords_sub.append(coords_sub) From 24f623f7836d672aec31c3a351e8f2266f5ec68c Mon Sep 17 00:00:00 2001 From: rhugonne Date: Tue, 6 Jul 2021 15:23:23 +0200 Subject: [PATCH 051/113] incremental commit for text --- docs/source/spatialstats.rst | 3 +++ examples/plot_nonstationary_error.py | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/source/spatialstats.rst b/docs/source/spatialstats.rst index 2a8ad38f..55a629e4 100644 --- a/docs/source/spatialstats.rst +++ b/docs/source/spatialstats.rst @@ -73,6 +73,9 @@ Workflow for DEM precision estimation Non-stationarity in elevation measurement errors ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +.. minigallery:: xdem.spstats.nd_binning + :add-heading: + Quantify and model non-stationarites """""""""""""""""""""""""""""""""""" diff --git a/examples/plot_nonstationary_error.py b/examples/plot_nonstationary_error.py index e6f99e7b..17d86152 100644 --- a/examples/plot_nonstationary_error.py +++ b/examples/plot_nonstationary_error.py @@ -8,7 +8,7 @@ Quantifying the non-stationarities in elevation measurement errors is essential to use stable terrain as a proxy for assessing the precision on other types of terrain (Hugonnet et al., in prep) and allows to standardize the measurement -errors to reach a stationary variance, an assumption necessary for spatial statistics (see :ref`spatialstats`). +errors to reach a stationary variance, an assumption necessary for spatial statistics (see :ref:`spatialstats`). Here, we show an example in which we identify terrain-related non-stationarities for a DEM difference of Longyearbyen glacier. We quantify those non-stationarities by `binning `_ robustly @@ -41,7 +41,7 @@ ddem.data[mask_glacier] = np.nan # %% -# We use the reference DEM to derive terrain variables such as slope, aspect, curvature (see :ref:`sphx_glr_auto_examples_plot_terrain_attributes`) +# We use the reference DEM to derive terrain variables such as slope, aspect, curvature (see :ref:`sphx_glr_auto_examples_plot_terrain_attributes.py`) # that we'll use to explore potential non-stationarities in elevation measurement error # We compute the slope, aspect, and both plan and profile curvatures: From 210fcbf05960150d307bf9758b1eddec329ffc38 Mon Sep 17 00:00:00 2001 From: rhugonne Date: Tue, 6 Jul 2021 19:04:21 +0200 Subject: [PATCH 052/113] updating variogram sampling with skgstat custom binning --- xdem/spstats.py | 207 +++++++++++++++++++++++++----------------------- 1 file changed, 109 insertions(+), 98 deletions(-) diff --git a/xdem/spstats.py b/xdem/spstats.py index 26d0cb99..119a029b 100644 --- a/xdem/spstats.py +++ b/xdem/spstats.py @@ -21,7 +21,8 @@ from skimage.draw import disk from scipy.interpolate import RegularGridInterpolator, LinearNDInterpolator, griddata from scipy.stats import binned_statistic, binned_statistic_2d, binned_statistic_dd -from xdem.spatial_tools import nmad, subsample_raster +from xdem.spatial_tools import nmad, subsample_raster, get_array_and_mask +from geoutils.georaster import RasterType, Raster with warnings.catch_warnings(): warnings.filterwarnings("ignore", category=DeprecationWarning) @@ -111,8 +112,8 @@ def interp_nd_binning(df: pd.DataFrame, list_var_names: Union[str,list[str]], st if min_count is not None: df_sub.loc[df_sub['count'] < min_count,statistic_name] = np.nan - vals = df_sub[statistic_name].values - ind_valid = np.isfinite(vals) + values = df_sub[statistic_name].values + ind_valid = np.isfinite(values) # re-check that the statistic data series contain valid data after filtering with min_count if all(~ind_valid): @@ -128,19 +129,19 @@ def interp_nd_binning(df: pd.DataFrame, list_var_names: Union[str,list[str]], st # griddata first to perform nearest interpolation with NaNs (irregular grid) # valid values - vals = vals[ind_valid] + values = values[ind_valid] # coordinates of valid values points_valid = tuple([df_sub[var].values[ind_valid] for var in list_var_names]) # grid coordinates bmid_grid = np.meshgrid(*list_bmid) points_grid = tuple([bmid_grid[i].flatten() for i in range(len(list_var_names))]) # fill grid no data with nearest neighbour - vals_grid = griddata(points_valid, vals, points_grid, method='nearest') - vals_grid = vals_grid.reshape(tuple(shape)) + values_grid = griddata(points_valid, values, points_grid, method='nearest') + values_grid = values_grid.reshape(tuple(shape)) # RegularGridInterpolator to perform linear interpolation/extrapolation on the grid # (will extrapolate only outside of boundaries not filled with the nearest of griddata as fill_value = None) - interp_fun = RegularGridInterpolator(tuple(list_bmid), vals_grid, method='linear', bounds_error=False, fill_value=None) + interp_fun = RegularGridInterpolator(tuple(list_bmid), values_grid, method='linear', bounds_error=False, fill_value=None) return interp_fun @@ -249,16 +250,16 @@ def nd_binning(values: np.ndarray, list_var: Iterable[np.ndarray], list_var_name return df_concat -def get_empirical_variogram(dh: np.ndarray, coords: np.ndarray, **kwargs) -> pd.DataFrame: +def get_empirical_variogram(values: np.ndarray, coords: np.ndarray, **kwargs) -> pd.DataFrame: """ Get empirical variogram from skgstat.Variogram object - :param dh: elevation differences + :param values: values :param coords: coordinates :return: empirical variogram (variance, lags, counts) """ - V = skg.Variogram(coordinates=coords, values=dh, normalize=False, **kwargs) + V = skg.Variogram(coordinates=coords, values=values, normalize=False, **kwargs) df = pd.DataFrame() df = df.assign(exp=V.experimental, bins=V.bins, count=V.count) @@ -277,7 +278,7 @@ def wrapper_get_empirical_variogram(argdict: dict, **kwargs) -> pd.DataFrame: """ print('Working on subsample '+str(argdict['i']) + ' out of '+str(argdict['max_i'])) - return get_empirical_variogram(dh=argdict['dh'], coords=argdict['coords'], **kwargs) + return get_empirical_variogram(values=argdict['values'], coords=argdict['coords'], **kwargs) def create_circular_mask(shape: Union[int, Sequence[int]], center: Optional[list[float]] = None, radius: Optional[float] = None) -> np.ndarray: @@ -334,118 +335,128 @@ def create_ring_mask(shape: Union[int, Sequence[int]], center: Optional[list[flo return mask_ring -def ring_subset(dh: np.ndarray, coords: np.ndarray, inside_radius: float = 0, outside_radius: float = 0) -> tuple[Union[np.ndarray, Any], Union[np.ndarray, Any]]: +def ring_subset(values: np.ndarray, coords: np.ndarray, inside_radius: float = 0, outside_radius: float = 0) -> tuple[Union[np.ndarray, Any], Union[np.ndarray, Any]]: """ - Subsampling of elevation differences within a ring/disk (to sample points at similar pairwise distances) + Subsampling of values within a ring/disk (to sample points at similar pairwise distances) - :param dh: elevation differences + :param values: values :param coords: coordinates :param inside_radius: radius of inside ring disk in pixels :param outside_radius: radius of outside ring disk in pixels - :return: subsets of dh and coords + :return: subsets of values and coords """ # select random center coordinates - nx, ny = np.shape(dh) + nx, ny = np.shape(values) center_x = np.random.choice(nx, 1) center_y = np.random.choice(ny, 1) mask_ring = create_ring_mask((nx,ny),center=(center_x,center_y),in_radius=inside_radius,out_radius=outside_radius) - dh_ring = dh[mask_ring] + values_ring = values[mask_ring] coords_ring = coords[mask_ring] - return dh_ring, coords_ring + return values_ring, coords_ring -def sample_multirange_empirical_variogram(dh: np.ndarray, gsd: float = None, coords: np.ndarray = None, - subsample: int = 10000, range_list: list = None, nrun: int = 1, nproc: int = 1, - **kwargs) -> pd.DataFrame: +def sample_multirange_variogram(values: Union[np.ndarray,RasterType], gsd: float = None, coords: np.ndarray = None, + subsample: int = 10000, multi_ranges: list[float] = None, nrun: int = 1, nproc: int = 1, + **kwargs) -> pd.DataFrame: """ - Wrapper to sample multi-range empirical variograms from the data. + Sample empirical variograms with binning adaptable to multiple ranges and subsampling adapted for raster data. + By default, subsamples into rings of varying radius between the pixel size and the extent of the provided raster. + Variograms are derived independently for several runs and ranges, and later aggregated, to more effectively sample + spatial lags at different order of magnitudes with the millions of samples of raster data. - If no option is passed, a varying binning is used with adapted ranges and data subsampling + If values are provided as a Raster subclass, nothing else is required. + If values are provided as a 2D array (M,N), a ground sampling distance is sufficient to derive the distances. + If values are provided as a 1D array (N), an array of coordinates (N,2) or (2,N) is expected. - :param dh: elevation differences - :param gsd: ground sampling distance (if array is 2D on structured grid) - :param coords: coordinates, to be used only with a flattened elevation differences array and passed as an array of \the pairs of coordinates: one dimension equal to two and the other to that of the flattened elevation differences - :param range_list: successive ranges with even binning - :param subsample: number of samples to randomly draw from the elevation differences - :param nrun: number of samplings + :param values: values + :param gsd: ground sampling distance + :param coords: coordinates + :param multi_ranges: list of ranges with successive subsampling and binning + :param subsample: number of samples to randomly draw from the values + :param nrun: number of runs :param nproc: number of processing cores :return: empirical variogram (variance, lags, counts) """ - # checks - dh = dh.squeeze() - if coords is None and gsd is None: - raise TypeError('Must provide either coordinates or ground sampling distance.') - elif gsd is not None and dh.ndim == 1: - raise TypeError('Array must be 2-dimensional when providing only ground sampling distance') - elif coords is not None and dh.ndim != 1: - raise TypeError('Coordinate array must be provided with 1-dimensional input array') + # First, check all that the values provided are OK + if isinstance(values, Raster): + coords = values.coords() + values, mask = get_array_and_mask(values.data) + elif isinstance(values, np.ndarray | np.ma.masked_array): + values, mask = get_array_and_mask(values) + else: + raise TypeError('Values must be of type np.ndarray, np.ma.masked_array or Raster subclass.') + + values = values.squeeze() + + # Then, check if the logic between values, coords and gsd is respected + if gsd is not None and values.ndim == 1: + raise TypeError('Values array must be 2D when providing ground sampling distance.') + elif coords is not None and values.ndim != 1: + raise TypeError('Values array must be 1D when providing coordinates.') elif coords is not None and (coords.shape[0] != 2 and coords.shape[1] != 2): - raise TypeError('One dimension of the coordinates array must be of length equal to 2') + raise TypeError('The coordinates array must have one dimension with length equal to 2') - # defaulting to xx and yy if those are provided + # Defaulting to coordinates if those are provided if coords is not None: if coords.shape[0] == 2 and coords.shape[1] != 2: coords = np.transpose(coords) + # Otherwise, we use the ground sampling distance else: - x, y = np.meshgrid(np.arange(0, dh.shape[0] * gsd, gsd), np.arange(0, dh.shape[1] * gsd, gsd)) + x, y = np.meshgrid(np.arange(0, values.shape[0] * gsd, gsd), np.arange(0, values.shape[1] * gsd, gsd)) coords = np.dstack((x.flatten(), y.flatten())).squeeze() - dh = dh.flatten() - - valid_data = np.isfinite(dh) - dh = dh[valid_data] - coords = coords[valid_data, :] - - # COMMENTING: custom binning is not supported by skgstat yet... - # if no range list is specified, define a default one based on the spatial extent of the data and its resolution - # if 'bin_func' not in kwargs.keys(): - # if range_list is None: - # - # # define max range as half the maximum distance between coordinates - # max_range = np.sqrt((np.max(coords[:,0])-np.min(coords[:,0]))**2+(np.max(coords[:,1])-np.min(coords[:,1]))**2)/2 - # - # # get the ground sampling distance - # if gsd is None: - # est_gsd = np.abs(coords[0,0] - coords[0,1]) - # else: - # est_gsd = gsd - # - # # define ranges as multiple of the resolution until they get close to the maximum range - # range_list = [] - # new_range = gsd - # while new_range < max_range/10: - # range_list.append(new_range) - # new_range *= 10 - # range_list.append(max_range) - # - # else: - # if range_list is not None: - # print('Both range_list and bin_func are defined for binning: defaulting to bin_func') - - # default value we want to use (kmeans is failing) + values = values.flatten() + + # Remove no data once the arrays are flattened + values = values[mask] + coords = coords[mask, :] + + # If no range list is specified, define a default one based on the spatial extent of the data and its resolution + if 'bin_func' not in kwargs.keys(): + + # If no multi_ranges are provided, define default behaviour + if multi_ranges is None: + + # Define the max range as the maximum distance between coordinates + max_range = np.sqrt((np.max(coords[:,0])-np.min(coords[:,0]))**2+(np.max(coords[:,1])-np.min(coords[:,1]))**2) + + # Get the ground sampling distance + if gsd is None: + gsd = np.sqrt((coords[0,0] - coords[0,1])**2 + (coords[0,0]-coords[1,0])**2) + + # Define list of ranges as exponent 2 of the resolution until the maximum range + multi_ranges = [] + # We start at 10 times the ground sampling distance + new_range = gsd*10 + while new_range < max_range/2: + multi_ranges.append(new_range) + new_range *= 2 + multi_ranges.append(max_range) + + # Default value we want to use if no binning function is defined if 'bin_func' not in kwargs.keys(): kwargs.update({'bin_func': 'even'}) if 'n_lags' not in kwargs.keys(): - kwargs.update({'n_lags': 100}) + kwargs.update({'n_lags': 10}) - # estimate variogram for multiple runs + # Estimate variogram for multiple runs if nrun == 1: - # subsetting - index = subsample_raster(dh, subsample=subsample, return_indices=True) - dh_sub = dh[index] + # Subset + index = subsample_raster(values, subsample=subsample, return_indices=True) + values_sub = values[index] coords_sub = coords[index, :] - # getting empirical variogram - df = get_empirical_variogram(dh=dh_sub, coords=coords_sub, **kwargs) + # Get empirical variogram + df = get_empirical_variogram(values=values_sub, coords=coords_sub, **kwargs) df['exp_sigma'] = np.nan else: - # multiple run only work for an even binning function for now (would need a customized binning not supported by skgstat) + # Multiple run only work for an even binning function for now (would need a customized binning not supported by skgstat) if kwargs.get('bin_func') is None: raise ValueError('Binning function must be "even" when doing multiple runs.') @@ -460,25 +471,25 @@ def sample_multirange_empirical_variogram(dh: np.ndarray, gsd: float = None, coo print('Using 1 core...') list_df_nb = [] for i in range(nrun): - index = subsample_raster(dh, subsample=subsample, return_indices=True) - dh_sub = dh[index] + index = subsample_raster(values, subsample=subsample, return_indices=True) + values_sub = values[index] coords_sub = coords[index, :] - df = get_empirical_variogram(dh=dh_sub, coords=coords_sub, **kwargs) + df = get_empirical_variogram(values=values_sub, coords=coords_sub, **kwargs) df['run'] = i list_df_nb.append(df) else: print('Using '+str(nproc) + ' cores...') - list_dh_sub = [] + list_values_sub = [] list_coords_sub = [] for i in range(nrun): - index = subsample_raster(dh, subsample=subsample, return_indices=True) - dh_sub = dh[index] + index = subsample_raster(values, subsample=subsample, return_indices=True) + values_sub = values[index] coords_sub = coords[index, :] - list_dh_sub.append(dh_sub) + list_values_sub.append(values_sub) list_coords_sub.append(coords_sub) pool = mp.Pool(nproc, maxtasksperchild=1) - argsin = [{'dh': list_dh_sub[i], 'coords': list_coords_sub[i], 'i':i, 'max_i':nrun} for i in range(nrun)] + argsin = [{'values': list_values_sub[i], 'coords': list_coords_sub[i], 'i':i, 'max_i':nrun} for i in range(nrun)] list_df = pool.map(partial(wrapper_get_empirical_variogram, **kwargs), argsin, chunksize=1) pool.close() pool.join() @@ -894,13 +905,13 @@ def double_sum_covar(list_tuple_errs: list[float], corr_ranges: list[float], lis return np.sqrt(var_err) -def patches_method(dh : np.ndarray, mask: np.ndarray[bool], gsd : float, area_size : float, perc_min_valid: float = 80., +def patches_method(values : np.ndarray, mask: np.ndarray[bool], gsd : float, area_size : float, perc_min_valid: float = 80., patch_shape: str = 'circular',nmax : int = 1000, verbose: bool = False) -> pd.DataFrame: """ Patches method for empirical estimation of the standard error over an integration area - :param dh: elevation differences + :param values: values :param mask: mask of sampled terrain :param gsd: ground sampling distance :param area_size: size of integration area @@ -914,13 +925,13 @@ def patches_method(dh : np.ndarray, mask: np.ndarray[bool], gsd : float, area_si """ # first, remove non sampled area (but we need to keep the 2D shape of raster for patch sampling) - dh = dh.squeeze() - valid_mask = np.logical_and(np.isfinite(dh), mask) - dh[~valid_mask] = np.nan + values = values.squeeze() + valid_mask = np.logical_and(np.isfinite(values), mask) + values[~valid_mask] = np.nan # divide raster in cadrants where we can sample - nx, ny = np.shape(dh) - count = len(dh[~np.isnan(dh)]) + nx, ny = np.shape(values) + count = len(values[~np.isnan(values)]) print('Number of valid pixels: ' + str(count)) nb_cadrant = int(np.floor(np.sqrt((count * gsd ** 2) / area_size) + 1)) # rectangular @@ -953,12 +964,12 @@ def patches_method(dh : np.ndarray, mask: np.ndarray[bool], gsd : float, area_si tile.append(str(i) + '_' + str(j)) if patch_shape == 'rectangular': - patch = dh[nx_sub * i:nx_sub * (i + 1), ny_sub * j:ny_sub * (j + 1)].flatten() + patch = values[nx_sub * i:nx_sub * (i + 1), ny_sub * j:ny_sub * (j + 1)].flatten() elif patch_shape == 'circular': center_x = np.floor(nx_sub*(i+1/2)) center_y = np.floor(ny_sub*(j+1/2)) mask = create_circular_mask((nx, ny), center=(center_x, center_y), radius=rad) - patch = dh[mask] + patch = values[mask] else: raise ValueError('Patch method must be rectangular or circular.') From 2a97d06cb76644df35303d30a00cacf15298f934 Mon Sep 17 00:00:00 2001 From: rhugonne Date: Fri, 16 Jul 2021 11:44:45 +0200 Subject: [PATCH 053/113] incremental commit for adapting to new skgstat --- tests/test_spstats.py | 20 ++--- xdem/spstats.py | 182 ++++++++++++++++++++++++++---------------- 2 files changed, 122 insertions(+), 80 deletions(-) diff --git a/tests/test_spstats.py b/tests/test_spstats.py index eaece641..c488e0ee 100644 --- a/tests/test_spstats.py +++ b/tests/test_spstats.py @@ -43,33 +43,33 @@ def test_empirical_fit_variogram_running(self): # check the base script runs with right input shape df = xdem.spstats.get_empirical_variogram( - dh=diff.data.flatten()[0:1000], - coords=coords[0:1000, :], + values=diff.data.flatten(), + coords=coords, nsamp=1000) # check the wrapper script runs with various inputs # with gsd as input - df_gsd = xdem.spstats.sample_multirange_empirical_variogram( - dh=diff.data, + df_gsd = xdem.spstats.sample_multirange_variogram( + values=diff.data, gsd=diff.res[0], - nsamp=1000) + nsamp=10) # with coords as input, and "uniform" bin_func - df_coords = xdem.spstats.sample_multirange_empirical_variogram( - dh=diff.data.flatten(), + df_coords = xdem.spstats.sample_multirange_variogram( + values=diff.data.flatten(), coords=coords, bin_func='uniform', nsamp=1000) # using more bins - df_1000_bins = xdem.spstats.sample_multirange_empirical_variogram( - dh=diff.data, + df_1000_bins = xdem.spstats.sample_multirange_variogram( + values=diff.data, gsd=diff.res[0], n_lags=1000, nsamp=1000) # using multiple runs with parallelized function - df_sig = xdem.spstats.sample_multirange_empirical_variogram(dh=diff.data, gsd=diff.res[0], nsamp=1000, + df_sig = xdem.spstats.sample_multirange_variogram(values=diff.data, gsd=diff.res[0], nsamp=1000, nrun=20, nproc=10, maxlag=10000) # test plotting diff --git a/xdem/spstats.py b/xdem/spstats.py index 119a029b..0ae0e932 100644 --- a/xdem/spstats.py +++ b/xdem/spstats.py @@ -259,10 +259,14 @@ def get_empirical_variogram(values: np.ndarray, coords: np.ndarray, **kwargs) -> :return: empirical variogram (variance, lags, counts) """ - V = skg.Variogram(coordinates=coords, values=values, normalize=False, **kwargs) + V = skg.Variogram(coordinates=coords, values=values, normalize=False, fit_method = None, **kwargs) + + # To derive the middle of the bins + bins, exp = V.get_empirical() + count = V.bin_count df = pd.DataFrame() - df = df.assign(exp=V.experimental, bins=V.bins, count=V.count) + df = df.assign(exp=exp, bins=bins, count=count) return df @@ -335,34 +339,46 @@ def create_ring_mask(shape: Union[int, Sequence[int]], center: Optional[list[flo return mask_ring -def ring_subset(values: np.ndarray, coords: np.ndarray, inside_radius: float = 0, outside_radius: float = 0) -> tuple[Union[np.ndarray, Any], Union[np.ndarray, Any]]: +def _subsample_wrapper(values: np.ndarray, coords: np.ndarray, shape: tuple[int,int] = None, subsample: int = 10000, + subsample_method: str = 'random_ring', inside_radius = None, outside_radius = None) -> tuple[np.ndarray, np.ndarray]: """ - Subsampling of values within a ring/disk (to sample points at similar pairwise distances) - - :param values: values - :param coords: coordinates - :param inside_radius: radius of inside ring disk in pixels - :param outside_radius: radius of outside ring disk in pixels - - :return: subsets of values and coords + Wrapper for subsample methods of sample_multirange_variogram """ + nx, ny = shape + + # subsample spatially for disk/ring methods + if subsample_method == 'random_disk': + # Select random center coordinates + center_x = np.random.choice(nx, 1)[0] + center_y = np.random.choice(ny, 1)[0] + index_ring = create_ring_mask((nx, ny), center=[center_x, center_y], in_radius=inside_radius, + out_radius=outside_radius) + index = index_ring.ravel() + + elif subsample_method == 'random_ring': + # Select random center coordinates + center_x = np.random.choice(nx, 1)[0] + center_y = np.random.choice(ny, 1)[0] + index_disk = create_circular_mask((nx, ny), center=[center_x, center_y], radius=inside_radius) + index = index_disk.ravel() + + if subsample_method in ['random_disk', 'random_ring']: + values_sp = values[index] + coords_sp = coords[index, :] + else: + values_sp = values + coords_sp = coords - # select random center coordinates - nx, ny = np.shape(values) - center_x = np.random.choice(nx, 1) - center_y = np.random.choice(ny, 1) - - mask_ring = create_ring_mask((nx,ny),center=(center_x,center_y),in_radius=inside_radius,out_radius=outside_radius) - - values_ring = values[mask_ring] - coords_ring = coords[mask_ring] + index = subsample_raster(values_sp, subsample=subsample, return_indices=True) + values_sub = values_sp[index] + coords_sub = coords_sp[index, :] - return values_ring, coords_ring + return values_sub, coords_sub def sample_multirange_variogram(values: Union[np.ndarray,RasterType], gsd: float = None, coords: np.ndarray = None, - subsample: int = 10000, multi_ranges: list[float] = None, nrun: int = 1, nproc: int = 1, - **kwargs) -> pd.DataFrame: + subsample: int = 10000, subsample_method: str = 'random_ring', + multi_ranges: list[float] = None, nrun: int = 1, nproc: int = 1, **kwargs) -> pd.DataFrame: """ Sample empirical variograms with binning adaptable to multiple ranges and subsampling adapted for raster data. By default, subsamples into rings of varying radius between the pixel size and the extent of the provided raster. @@ -373,9 +389,15 @@ def sample_multirange_variogram(values: Union[np.ndarray,RasterType], gsd: float If values are provided as a 2D array (M,N), a ground sampling distance is sufficient to derive the distances. If values are provided as a 1D array (N), an array of coordinates (N,2) or (2,N) is expected. + Spatial subsampling method argument subsample_method can be one of "random_point", "random_disk" and "random_ring". + A list of ranges to subsample independently can be passed through multi_ranges as a list of successive maximum ranges. + If the subsampling method selected is "random_point", the multi-range argument is ignored as range has no effect on + this subsampling method. + :param values: values :param gsd: ground sampling distance :param coords: coordinates + :param subsample_method: spatial subsampling method :param multi_ranges: list of ranges with successive subsampling and binning :param subsample: number of samples to randomly draw from the values :param nrun: number of runs @@ -387,44 +409,58 @@ def sample_multirange_variogram(values: Union[np.ndarray,RasterType], gsd: float if isinstance(values, Raster): coords = values.coords() values, mask = get_array_and_mask(values.data) - elif isinstance(values, np.ndarray | np.ma.masked_array): + elif isinstance(values, (np.ndarray, np.ma.masked_array)): values, mask = get_array_and_mask(values) else: raise TypeError('Values must be of type np.ndarray, np.ma.masked_array or Raster subclass.') - values = values.squeeze() # Then, check if the logic between values, coords and gsd is respected - if gsd is not None and values.ndim == 1: - raise TypeError('Values array must be 2D when providing ground sampling distance.') + if (gsd is not None or subsample_method in ['random_disk','random_ring']) and values.ndim == 1: + raise TypeError('Values array must be 2D when providing ground sampling distance, or random disk/ring method.') elif coords is not None and values.ndim != 1: raise TypeError('Values array must be 1D when providing coordinates.') elif coords is not None and (coords.shape[0] != 2 and coords.shape[1] != 2): raise TypeError('The coordinates array must have one dimension with length equal to 2') + if subsample_method not in ['random_point','random_disk','random_ring']: + raise TypeError('The subsampling method must be one of "random_point", "random_disk" or "random_ring".') + # Defaulting to coordinates if those are provided if coords is not None: + nx = None + ny = None if coords.shape[0] == 2 and coords.shape[1] != 2: coords = np.transpose(coords) # Otherwise, we use the ground sampling distance else: + nx, ny = np.shape(values) x, y = np.meshgrid(np.arange(0, values.shape[0] * gsd, gsd), np.arange(0, values.shape[1] * gsd, gsd)) coords = np.dstack((x.flatten(), y.flatten())).squeeze() values = values.flatten() - # Remove no data once the arrays are flattened - values = values[mask] - coords = coords[mask, :] - - # If no range list is specified, define a default one based on the spatial extent of the data and its resolution + # Default value we want to use if no binning function, number of lags, and maximum lags are defined if 'bin_func' not in kwargs.keys(): + kwargs.update({'bin_func': 'even'}) + if 'n_lags' not in kwargs.keys(): + kwargs.update({'n_lags': 10}) + if 'maxlag' not in kwargs.keys(): + # define maximum lag as the maximum distance between coordinates (needed to provide custom bins, otherwise + # skgstat rewrites the maxlag with the subsample of coordinates provided) + if coords is not None: + maxlag = np.sqrt((np.max(coords[:, 0]) - np.min(coords[:, 0])) ** 2 + + (np.max(coords[:, 1]) - np.min(coords[:, 1])) ** 2) / 2 + else: + maxlag = np.sqrt((nx*gsd)**2 + (ny*gsd)**2) + # also need a cutoff value to get the exact same bins + kwargs.update({'maxlag': maxlag}) + else: + maxlag = kwargs.get('maxlag') - # If no multi_ranges are provided, define default behaviour - if multi_ranges is None: - - # Define the max range as the maximum distance between coordinates - max_range = np.sqrt((np.max(coords[:,0])-np.min(coords[:,0]))**2+(np.max(coords[:,1])-np.min(coords[:,1]))**2) + # If no multi_ranges are provided, define a logical default behaviour with the pixel size and grid size + if subsample_method in ['random_disk','random_ring']: + if multi_ranges is None: # Get the ground sampling distance if gsd is None: gsd = np.sqrt((coords[0,0] - coords[0,1])**2 + (coords[0,0]-coords[1,0])**2) @@ -433,47 +469,45 @@ def sample_multirange_variogram(values: Union[np.ndarray,RasterType], gsd: float multi_ranges = [] # We start at 10 times the ground sampling distance new_range = gsd*10 - while new_range < max_range/2: + while new_range < maxlag/2: multi_ranges.append(new_range) new_range *= 2 - multi_ranges.append(max_range) + multi_ranges.append(maxlag) - # Default value we want to use if no binning function is defined - if 'bin_func' not in kwargs.keys(): - kwargs.update({'bin_func': 'even'}) - if 'n_lags' not in kwargs.keys(): - kwargs.update({'n_lags': 10}) + # Define subsampling parameters + list_inside_radius, list_outside_radius = ([] for i in range(2)) + binned_ranges = [0] + multi_ranges + for i in range(len(binned_ranges)-1): - # Estimate variogram for multiple runs - if nrun == 1: - # Subset - index = subsample_raster(values, subsample=subsample, return_indices=True) - values_sub = values[index] - coords_sub = coords[index, :] - # Get empirical variogram - df = get_empirical_variogram(values=values_sub, coords=coords_sub, **kwargs) - df['exp_sigma'] = np.nan + outside_radius = binned_ranges[i+1]+5*gsd + if subsample_method == 'random_ring': + inside_radius = binned_ranges[i] + else: + inside_radius = None + list_outside_radius.append(outside_radius) + list_inside_radius.append(inside_radius) else: + # For random point selection, no need for multi-range parameters + multi_ranges = [maxlag] + list_outside_radius = [None] + list_inside_radius = [None] - # Multiple run only work for an even binning function for now (would need a customized binning not supported by skgstat) - if kwargs.get('bin_func') is None: - raise ValueError('Binning function must be "even" when doing multiple runs.') - - # define max range as half the maximum distance between coordinates - max_range = np.sqrt((np.max(coords[:, 0])-np.min(coords[:, 0]))**2 + - (np.max(coords[:, 1])-np.min(coords[:, 1]))**2)/2 - # also need a cutoff value to get the exact same bins - if 'maxlag' not in kwargs.keys(): - kwargs.update({'maxlag': max_range}) + # Estimate variogram with specific subsampling at multiple ranges + list_df_r = [] + for r in multi_ranges: + # Differentiate between 1 core and several cores for multiple runs if nproc == 1: print('Using 1 core...') list_df_nb = [] for i in range(nrun): - index = subsample_raster(values, subsample=subsample, return_indices=True) - values_sub = values[index] - coords_sub = coords[index, :] + values_sub, coords_sub = _subsample_wrapper(values, coords, shape=(nx,ny), subsample=subsample, subsample_method=subsample_method, + inside_radius=list_inside_radius[multi_ranges.index(r)], outside_radius=list_outside_radius[multi_ranges.index(r)]) + print(values_sub) + print(coords_sub) + if len(values_sub) == 0: + continue df = get_empirical_variogram(values=values_sub, coords=coords_sub, **kwargs) df['run'] = i list_df_nb.append(df) @@ -489,7 +523,7 @@ def sample_multirange_variogram(values: Union[np.ndarray,RasterType], gsd: float list_coords_sub.append(coords_sub) pool = mp.Pool(nproc, maxtasksperchild=1) - argsin = [{'values': list_values_sub[i], 'coords': list_coords_sub[i], 'i':i, 'max_i':nrun} for i in range(nrun)] + argsin = [{'values': list_values_sub[i], 'coords': list_coords_sub[i], 'i':i, 'max_i': nrun} for i in range(nrun)] list_df = pool.map(partial(wrapper_get_empirical_variogram, **kwargs), argsin, chunksize=1) pool.close() pool.join() @@ -500,9 +534,18 @@ def sample_multirange_variogram(values: Union[np.ndarray,RasterType], gsd: float df_nb['run'] = i list_df_nb.append(df_nb) - df = pd.concat(list_df_nb) + # Aggregate runs + df_r = pd.concat(list_df_nb) + list_df_r.append(df_r) + + # Aggregate multiple ranges subsampling + df = pd.concat(list_df_r) - # group results, use mean as empirical variogram, estimate sigma, and sum the counts + # For a single run, no multi-run sigma estimated + if nrun == 1: + df['exp_sigma'] = np.nan + # For several runs, group results, use mean as empirical variogram, estimate sigma, and sum the counts + else: df_grouped = df.groupby('bins', dropna=False) df_mean = df_grouped[['exp']].mean() df_sig = df_grouped[['exp']].std() @@ -515,7 +558,6 @@ def sample_multirange_variogram(values: Union[np.ndarray,RasterType], gsd: float return df - def fit_model_sum_vgm(list_model: list[str], emp_vgm_df: pd.DataFrame) -> tuple[Callable, list[float]]: """ Fit a multi-range variogram model to an empirical variogram, weighted based on sampling and elevation errors From e5933751a2e2d821423579ece909d60ade86b06d Mon Sep 17 00:00:00 2001 From: rhugonne Date: Fri, 16 Jul 2021 13:37:55 +0200 Subject: [PATCH 054/113] pr comments + refactor name --- .../{test_spstats.py => test_spatialstats.py} | 74 +++++++++---------- xdem/{spstats.py => spatialstats.py} | 15 ++-- 2 files changed, 46 insertions(+), 43 deletions(-) rename tests/{test_spstats.py => test_spatialstats.py} (75%) rename xdem/{spstats.py => spatialstats.py} (98%) diff --git a/tests/test_spstats.py b/tests/test_spatialstats.py similarity index 75% rename from tests/test_spstats.py rename to tests/test_spatialstats.py index c488e0ee..f8879926 100644 --- a/tests/test_spstats.py +++ b/tests/test_spatialstats.py @@ -42,50 +42,50 @@ def test_empirical_fit_variogram_running(self): coords = np.dstack((x.flatten(), y.flatten())).squeeze() # check the base script runs with right input shape - df = xdem.spstats.get_empirical_variogram( + df = xdem.spatialstats.get_empirical_variogram( values=diff.data.flatten(), coords=coords, nsamp=1000) # check the wrapper script runs with various inputs # with gsd as input - df_gsd = xdem.spstats.sample_multirange_variogram( + df_gsd = xdem.spatialstats.sample_multirange_variogram( values=diff.data, gsd=diff.res[0], nsamp=10) # with coords as input, and "uniform" bin_func - df_coords = xdem.spstats.sample_multirange_variogram( + df_coords = xdem.spatialstats.sample_multirange_variogram( values=diff.data.flatten(), coords=coords, bin_func='uniform', nsamp=1000) # using more bins - df_1000_bins = xdem.spstats.sample_multirange_variogram( + df_1000_bins = xdem.spatialstats.sample_multirange_variogram( values=diff.data, gsd=diff.res[0], n_lags=1000, nsamp=1000) # using multiple runs with parallelized function - df_sig = xdem.spstats.sample_multirange_variogram(values=diff.data, gsd=diff.res[0], nsamp=1000, - nrun=20, nproc=10, maxlag=10000) + df_sig = xdem.spatialstats.sample_multirange_variogram(values=diff.data, gsd=diff.res[0], nsamp=1000, + nrun=20, nproc=10, maxlag=10000) # test plotting if PLOT: - xdem.spstats.plot_vgm(df_sig) + xdem.spatialstats.plot_vgm(df_sig) # single model fit - fun, _ = xdem.spstats.fit_model_sum_vgm(['Sph'], df_sig) + fun, _ = xdem.spatialstats.fit_model_sum_vgm(['Sph'], df_sig) if PLOT: - xdem.spstats.plot_vgm(df_sig, list_fit_fun=[fun]) + xdem.spatialstats.plot_vgm(df_sig, list_fit_fun=[fun]) try: # triple model fit - fun2, _ = xdem.spstats.fit_model_sum_vgm(['Sph', 'Sph', 'Sph'], emp_vgm_df=df_sig) + fun2, _ = xdem.spatialstats.fit_model_sum_vgm(['Sph', 'Sph', 'Sph'], emp_vgm_df=df_sig) if PLOT: - xdem.spstats.plot_vgm(df_sig, list_fit_fun=[fun2]) + xdem.spatialstats.plot_vgm(df_sig, list_fit_fun=[fun2]) except RuntimeError as exception: if "The maximum number of function evaluations is exceeded." not in str(exception): raise exception @@ -110,10 +110,10 @@ def test_multirange_fit_performance(self): df = df.assign(bins=x, exp=y_simu, exp_sigma=sig) # then, run the fitting - fun, params = xdem.spstats.fit_model_sum_vgm(['Sph', 'Sph', 'Sph'], df) + fun, params = xdem.spatialstats.fit_model_sum_vgm(['Sph', 'Sph', 'Sph'], df) if PLOT: - xdem.spstats.plot_vgm(df, fit_fun=fun) + xdem.spatialstats.plot_vgm(df, fit_fun=fun) def test_neff_estimation(self): @@ -133,9 +133,9 @@ def test_neff_estimation(self): # and for any glacier area for area in [10**i for i in range(10)]: - neff_circ_exact = xdem.spstats.exact_neff_sphsum_circular( + neff_circ_exact = xdem.spatialstats.exact_neff_sphsum_circular( area=area, crange1=r1, psill1=p1, crange2=r2, psill2=p2) - neff_circ_numer = xdem.spstats.neff_circ(area, [(r1, 'Sph', p1), (r2, 'Sph', p2)]) + neff_circ_numer = xdem.spatialstats.neff_circ(area, [(r1, 'Sph', p1), (r2, 'Sph', p2)]) assert np.abs(neff_circ_exact-neff_circ_numer) < 0.001 @@ -145,8 +145,8 @@ def test_circular_masking(self): """Test that the circular masking works as intended""" # using default (center should be [2,2], radius 2) - circ = xdem.spstats.create_circular_mask((5,5)) - circ2 = xdem.spstats.create_circular_mask((5,5),center=[2,2],radius=2) + circ = xdem.spatialstats.create_circular_mask((5, 5)) + circ2 = xdem.spatialstats.create_circular_mask((5, 5), center=[2, 2], radius=2) # check default center and radius are derived properly assert np.array_equal(circ,circ2) @@ -159,14 +159,14 @@ def test_circular_masking(self): # check distance is not a multiple of pixels (more accurate subsampling) # will create a 1-pixel mask around the center - circ3 = xdem.spstats.create_circular_mask((5,5),center=[1,1],radius=1) + circ3 = xdem.spatialstats.create_circular_mask((5, 5), center=[1, 1], radius=1) eq_circ3 = np.zeros((5,5), dtype=bool) eq_circ3[1,1] = True assert np.array_equal(circ3, eq_circ3) # will create a square mask (<1.5 pixel) around the center - circ4 = xdem.spstats.create_circular_mask((5,5),center=[1,1],radius=1.5) + circ4 = xdem.spatialstats.create_circular_mask((5, 5), center=[1, 1], radius=1.5) # should not be the same as radius = 1 assert not np.array_equal(circ3,circ4) @@ -175,15 +175,15 @@ def test_ring_masking(self): """Test that the ring masking works as intended""" # by default, the mask is only an outside circle (ring of size 0) - ring1 = xdem.spstats.create_ring_mask((5,5)) - circ1 = xdem.spstats.create_circular_mask((5,5)) + ring1 = xdem.spatialstats.create_ring_mask((5, 5)) + circ1 = xdem.spatialstats.create_circular_mask((5, 5)) assert np.array_equal(ring1,circ1) # test rings with different inner radius - ring2 = xdem.spstats.create_ring_mask((5,5),in_radius=1,out_radius=2) - ring3 = xdem.spstats.create_ring_mask((5,5),in_radius=0,out_radius=2) - ring4 = xdem.spstats.create_ring_mask((5,5),in_radius=1.5,out_radius=2) + ring2 = xdem.spatialstats.create_ring_mask((5, 5), in_radius=1, out_radius=2) + ring3 = xdem.spatialstats.create_ring_mask((5, 5), in_radius=0, out_radius=2) + ring4 = xdem.spatialstats.create_ring_mask((5, 5), in_radius=1.5, out_radius=2) assert np.logical_and(~np.array_equal(ring2,ring3),~np.array_equal(ring3,ring4)) @@ -202,7 +202,7 @@ def test_patches_method(self): warnings.filterwarnings("error") # check the patches method runs - df_patches = xdem.spstats.patches_method( + df_patches = xdem.spatialstats.patches_method( diff.data.squeeze(), mask=~mask.astype(bool).squeeze(), gsd=diff.res[0], @@ -218,7 +218,7 @@ def test_nd_binning(self): slope, aspect = xdem.coreg.calculate_slope_and_aspect(ref.data.squeeze()) # 1d binning, by default will create 10 bins - df = xdem.spstats.nd_binning(values=diff.data.flatten(),list_var=[slope.flatten()],list_var_names=['slope']) + df = xdem.spatialstats.nd_binning(values=diff.data.flatten(), list_var=[slope.flatten()], list_var_names=['slope']) # check length matches assert df.shape[0] == 10 @@ -227,8 +227,8 @@ def test_nd_binning(self): assert np.nanmax(slope) == np.max(pd.IntervalIndex(df.slope).right) # 1d binning with 20 bins - df = xdem.spstats.nd_binning(values=diff.data.flatten(), list_var=[slope.flatten()], list_var_names=['slope'], - list_var_bins=[[20]]) + df = xdem.spatialstats.nd_binning(values=diff.data.flatten(), list_var=[slope.flatten()], list_var_names=['slope'], + list_var_bins=[[20]]) # check length matches assert df.shape[0] == 20 @@ -240,16 +240,16 @@ def percentile_80(a): return np.nanpercentile(a, 80) # check the function runs with custom functions - xdem.spstats.nd_binning(values=diff.data.flatten(),list_var=[slope.flatten()],list_var_names=['slope'], statistics=['count',percentile_80]) + xdem.spatialstats.nd_binning(values=diff.data.flatten(), list_var=[slope.flatten()], list_var_names=['slope'], statistics=['count', percentile_80]) # 2d binning - df = xdem.spstats.nd_binning(values=diff.data.flatten(),list_var=[slope.flatten(),ref.data.flatten()],list_var_names=['slope','elevation']) + df = xdem.spatialstats.nd_binning(values=diff.data.flatten(), list_var=[slope.flatten(), ref.data.flatten()], list_var_names=['slope', 'elevation']) # dataframe should contain two 1D binning of length 10 and one 2D binning of length 100 assert df.shape[0] == (10 + 10 + 100) # nd binning - df = xdem.spstats.nd_binning(values=diff.data.flatten(),list_var=[slope.flatten(),ref.data.flatten(),aspect.flatten()],list_var_names=['slope','elevation','aspect']) + df = xdem.spatialstats.nd_binning(values=diff.data.flatten(), list_var=[slope.flatten(), ref.data.flatten(), aspect.flatten()], list_var_names=['slope', 'elevation', 'aspect']) # dataframe should contain three 1D binning of length 10 and three 2D binning of length 100 and one 2D binning of length 1000 assert df.shape[0] == (1000 + 3 * 100 + 3 * 10) @@ -260,7 +260,7 @@ def test_interp_nd_binning(self): df = pd.DataFrame({"var1": [1, 1, 1, 2, 2, 2, 3, 3, 3], "var2": [1, 2, 3, 1, 2, 3, 1, 2, 3], "statistic": [1, 2, 3, 4, 5, 6, 7, 8, 9]}) arr = np.array([1, 2, 3, 4, 5, 6, 7, 8, 9]).reshape((3,3)) - fun = xdem.spstats.interp_nd_binning(df, list_var_names=["var1", "var2"], statistic="statistic", min_count=None) + fun = xdem.spatialstats.interp_nd_binning(df, list_var_names=["var1", "var2"], statistic="statistic", min_count=None) # check interpolation falls right on values for points (1, 1), (1, 2) etc... for i in range(3): @@ -312,10 +312,10 @@ def test_interp_nd_binning(self): ref, diff, mask = load_ref_and_diff() slope, aspect = xdem.coreg.calculate_slope_and_aspect(ref.data.squeeze()) - df = xdem.spstats.nd_binning(values=diff.data.flatten(),list_var=[slope.flatten(),ref.data.flatten(),aspect.flatten()],list_var_names=['slope','elevation','aspect']) + df = xdem.spatialstats.nd_binning(values=diff.data.flatten(), list_var=[slope.flatten(), ref.data.flatten(), aspect.flatten()], list_var_names=['slope', 'elevation', 'aspect']) # in 1d - fun = xdem.spstats.interp_nd_binning(df, list_var_names='slope') + fun = xdem.spatialstats.interp_nd_binning(df, list_var_names='slope') # check a value is returned inside the grid assert np.isfinite(fun([15])) @@ -325,7 +325,7 @@ def test_interp_nd_binning(self): assert all(np.isfinite(fun([-5,50]))) # in 2d - fun = xdem.spstats.interp_nd_binning(df, list_var_names=['slope','elevation']) + fun = xdem.spatialstats.interp_nd_binning(df, list_var_names=['slope', 'elevation']) # check a value is returned inside the grid assert np.isfinite(fun([15, 1000])) @@ -335,8 +335,8 @@ def test_interp_nd_binning(self): assert all(np.isfinite(fun(([-5, 50],[-500,3000])))) # in 3d, let's decrease the number of bins to get something with enough samples - df = xdem.spstats.nd_binning(values=diff.data.flatten(),list_var=[slope.flatten(),ref.data.flatten(),aspect.flatten()],list_var_names=['slope','elevation','aspect'], list_var_bins=3) - fun = xdem.spstats.interp_nd_binning(df, list_var_names=['slope','elevation','aspect']) + df = xdem.spatialstats.nd_binning(values=diff.data.flatten(), list_var=[slope.flatten(), ref.data.flatten(), aspect.flatten()], list_var_names=['slope', 'elevation', 'aspect'], list_var_bins=3) + fun = xdem.spatialstats.interp_nd_binning(df, list_var_names=['slope', 'elevation', 'aspect']) # check a value is returned inside the grid assert np.isfinite(fun([15,1000, np.pi])) diff --git a/xdem/spstats.py b/xdem/spatialstats.py similarity index 98% rename from xdem/spstats.py rename to xdem/spatialstats.py index 0ae0e932..d4a4257b 100644 --- a/xdem/spstats.py +++ b/xdem/spatialstats.py @@ -1036,10 +1036,13 @@ def patches_method(values : np.ndarray, mask: np.ndarray[bool], gsd : float, are def plot_vgm(df: pd.DataFrame, list_fit_fun: Optional[list[Callable[[float],float]]] = None, list_fit_fun_label: Optional[list[str]] = None): """ - Plot empirical variogram, with optionally one or several model fits + Plot empirical variogram, with optionally one or several model fits. + Input dataframe is expected to be the output of xdem.spatialstats.sample_multirange_variogram. + Input function model is expected to be the output of xdem.spatialstats.fit_model_sum_vgm. + :param df: dataframe of empirical variogram - :param list_fit_fun: list of function fits - :param list_fit_fun_label: list of function fits labels + :param list_fit_fun: list of model function fits + :param list_fit_fun_label: list of model function fits labels :param :return: """ @@ -1071,8 +1074,8 @@ def plot_vgm(df: pd.DataFrame, list_fit_fun: Optional[list[Callable[[float],floa def plot_1d_binning(df: pd.DataFrame, var_name: str, statistic_name: str, label_var: Optional[str] = None, label_statistic: Optional[str] = None, min_count: int = 30): """ - Plot one statistic and its count along a single binning variable. - Input is expected to be formatted as the output of the nd_binning function. + Plot a statistic and its count along a single binning variable. + Input is expected to be formatted as the output of the xdem.spatialstats.nd_binning function. :param df: output dataframe of nd_binning :param var_name: name of binning variable to plot @@ -1135,7 +1138,7 @@ def plot_2d_binning(df: pd.DataFrame, var_name_1: str, var_name_2: str, statisti nodata_color: Union[str,tuple[float,float,float,float]] ='yellow'): """ Plot one statistic and its count along two binning variables. - Input is expected to be formatted as the output of the nd_binning function. + Input is expected to be formatted as the output of the xdem.spatialstats.nd_binning function. :param df: output dataframe of nd_binning :param var_name_1: name of first binning variable to plot From e579031144c5ea54d8c595af73971aa728ed6d26 Mon Sep 17 00:00:00 2001 From: rhugonne Date: Fri, 16 Jul 2021 13:39:15 +0200 Subject: [PATCH 055/113] refactor spstats into spatialstats --- docs/source/code/spatialstats.py | 11 ++-- docs/source/code/spatialstats_plot_vgm.py | 9 ++-- docs/source/intro.rst | 12 +++-- docs/source/spatialstats.rst | 11 ++-- examples/plot_nonstationary_error.py | 63 +++++++++++----------- examples/plot_vgm_error.py | 66 +++++++++++------------ xdem/__init__.py | 2 +- 7 files changed, 91 insertions(+), 83 deletions(-) diff --git a/docs/source/code/spatialstats.py b/docs/source/code/spatialstats.py index 5547f51a..bb362c5a 100644 --- a/docs/source/code/spatialstats.py +++ b/docs/source/code/spatialstats.py @@ -1,3 +1,4 @@ +"""Code example for spatial statistics""" import xdem import geoutils as gu import numpy as np @@ -14,20 +15,20 @@ ddem.data[mask] = np.nan # Get non-stationarities by bins -df_ns = xdem.spstats.nd_binning(ddem.data.ravel(),list_var=[slope.ravel()],list_var_names=['slope']) +df_ns = xdem.spatialstats.nd_binning(ddem.data.ravel(), list_var=[slope.ravel()], list_var_names=['slope']) # Sample empirical variogram -df_vgm = xdem.spstats.sample_multirange_empirical_variogram(dh=ddem.data, nsamp=1000, nrun=20, nproc=10, maxlag=10000) +df_vgm = xdem.spatialstats.sample_multirange_empirical_variogram(dh=ddem.data, nsamp=1000, nrun=20, nproc=10, maxlag=10000) # Fit single-range spherical model -fun, coefs = xdem.spstats.fit_model_sum_vgm(['Sph'], emp_vgm_df=df_vgm) +fun, coefs = xdem.spatialstats.fit_model_sum_vgm(['Sph'], emp_vgm_df=df_vgm) # Fit sum of triple-range spherical model -fun2, coefs2 = xdem.spstats.fit_model_sum_vgm(['Sph', 'Sph', 'Sph'], emp_vgm_df=df_vgm) +fun2, coefs2 = xdem.spatialstats.fit_model_sum_vgm(['Sph', 'Sph', 'Sph'], emp_vgm_df=df_vgm) # Calculate the area-averaged uncertainty with these models list_vgm = [(coefs[2*i],'Sph',coefs[2*i+1]) for i in range(int(len(coefs)/2))] -neff = xdem.spstats.neff_circ(1,list_vgm) +neff = xdem.spatialstats.neff_circ(1, list_vgm) diff --git a/docs/source/code/spatialstats_plot_vgm.py b/docs/source/code/spatialstats_plot_vgm.py index f813e6ac..b6075214 100644 --- a/docs/source/code/spatialstats_plot_vgm.py +++ b/docs/source/code/spatialstats_plot_vgm.py @@ -1,3 +1,4 @@ +"""Plot example for variogram""" import matplotlib.pyplot as plt import geoutils as gu @@ -16,11 +17,11 @@ np.random.seed(42) # sample empirical variogram -df = xdem.spstats.sample_multirange_empirical_variogram(dh=ddem.data, gsd=ddem.res[0], nsamp=1000, nrun=20, maxlag=4000) +df = xdem.spatialstats.sample_multirange_empirical_variogram(dh=ddem.data, gsd=ddem.res[0], nsamp=1000, nrun=20, maxlag=4000) -fun, _ = xdem.spstats.fit_model_sum_vgm(['Sph'], df) -fun2, _ = xdem.spstats.fit_model_sum_vgm(['Sph', 'Sph', 'Sph'], emp_vgm_df=df) -xdem.spstats.plot_vgm(df, list_fit_fun=[fun, fun2],list_fit_fun_label=['Spherical model','Sum of three spherical models']) +fun, _ = xdem.spatialstats.fit_model_sum_vgm(['Sph'], df) +fun2, _ = xdem.spatialstats.fit_model_sum_vgm(['Sph', 'Sph', 'Sph'], emp_vgm_df=df) +xdem.spatialstats.plot_vgm(df, list_fit_fun=[fun, fun2], list_fit_fun_label=['Spherical model', 'Sum of three spherical models']) plt.show() diff --git a/docs/source/intro.rst b/docs/source/intro.rst index 45e7d06e..2e9072f4 100644 --- a/docs/source/intro.rst +++ b/docs/source/intro.rst @@ -8,7 +8,7 @@ Digital Elevation Models are numerical, gridded representations of elevation. Th While some complexities are specific to certain instruments and methods, all DEMs generally possess: - a `ground sampling distance `_ (GSD), or pixel size, **that does not necessarily represent the underlying spatial resolution of the observations**, -- a `georeferencing `_ **that can subject to shifts, tilts or other deformations** due to inherent instrument errors, noise, or associated post-processing schemes, +- a `georeferencing `_ **that can be subject to shifts, tilts or other deformations** due to inherent instrument errors, noise, or associated processing schemes, - a large number of `outliers `_ **that remain difficult to filter** as they can originate from various sources (e.g., photogrammetric blunders, clouds). These factors lead to difficulties in assessing the accuracy and precision of DEMs, which are necessary to perform further analysis. @@ -41,12 +41,15 @@ TODO: Add another little schematic! Optimizing DEM absolute accuracy ********************************** -Shifts due to poor absolute accuracy are common in elevation datasets, and can be easily corrected by performing a DEM co-registration to precise and accurate, quality-controlled elevation data such as ICESat and ICESat-2. +Shifts due to poor absolute accuracy are common in elevation datasets, and can be easily corrected by performing a DEM +co-registration to precise and accurate, quality-controlled elevation data such as `ICESat `_ +and `ICESat-2 `_. Quality-controlled DEMs aligned on high-accuracy data also exists, such as TanDEM-X global DEM (see `Rizzoli et al. (2017) `_) Those biases can be corrected using the methods described in :ref:`coregistration`. -TODO: Add a point data - DEM co-registration plot +.. minigallery:: xdem.coreg.NuthKaab + :add-heading: Optimizing DEM relative accuracy ********************************** @@ -72,4 +75,5 @@ However, the lack of implementations of these methods in a modern programming la The tools for quantifying DEM precision are described in :ref:`spatialstats`. -TODO: Add a plot summarizing a DEM precision quantification +.. minigallery:: xdem.spatialstats.nd_binning + :add-heading: \ No newline at end of file diff --git a/docs/source/spatialstats.rst b/docs/source/spatialstats.rst index 55a629e4..140c859f 100644 --- a/docs/source/spatialstats.rst +++ b/docs/source/spatialstats.rst @@ -48,16 +48,16 @@ Quantifying the precision of a single DEM, or of a difference of DEMs To statistically infer the precision of a DEM, the DEM has to be compared against independent elevation observations. -If the other elevation data is known to be of higher-precision, one can assume that the analysis of differences will represent the precision of the rougher DEM. +Significant measurement errors can originate from both sets of elevation observations, and the analysis of differences will represent the mixed precision of the two. +As there is no reason for a dependency between the elevation data sets, the analysis of elevation differences yields: .. math:: - \sigma_{dh} = \sigma_{h_{\textrm{higher precision}} - h_{\textrm{lower precision}}} \approx \sigma_{h_{\textrm{lower precision}}} + \sigma_{dh} = \sigma_{h_{\textrm{precision1}} - h_{\textrm{precision2}}} = \sqrt{\sigma_{h_{\textrm{precision1}}}^{2} + \sigma_{h_{\textrm{precision2}}}^{2}} -Otherwise, significant measurement errors can originate from both sets of elevation observations, and the analysis of differences will represent the mixed precision of the two. -As there is no reason a priori for a depedency between the elevation data sets, the analysis will yield: +If the other elevation data is known to be of higher-precision, one can assume that the analysis of differences will represent only the precision of the rougher DEM. .. math:: - \sigma_{dh} = \sigma_{h_{\textrm{precision1}} - h_{\textrm{precision2}}} = \sqrt{\sigma_{h_{\textrm{precision1}}}^{2} + \sigma_{h_{\textrm{precision2}}}^{2}} + \sigma_{dh} = \sigma_{h_{\textrm{higher precision}} - h_{\textrm{lower precision}}} \approx \sigma_{h_{\textrm{lower precision}}} TODO: complete with Hugonnet et al. (in prep) @@ -128,6 +128,7 @@ Metrics for DEM precision Historically, the precision of DEMs has been reported as a single value indicating the random error at the scale of a single pixel, for example :math:`\pm 2` meters. However, there is several limitations to this metric: + - studies have shown significant variability of elevation measurement errors with terrain attributes, such as the slope, but also with the type of terrain diff --git a/examples/plot_nonstationary_error.py b/examples/plot_nonstationary_error.py index 17d86152..3d330855 100644 --- a/examples/plot_nonstationary_error.py +++ b/examples/plot_nonstationary_error.py @@ -32,13 +32,13 @@ # the :ref:`sphx_glr_auto_examples_plot_nuth_kaab.py` example. We later refer to those elevation differences as *dh*. ref_dem = xdem.DEM(xdem.examples.get_path("longyearbyen_ref_dem")) -ddem = xdem.DEM(xdem.examples.get_path("longyearbyen_ddem")) +dh = xdem.DEM(xdem.examples.get_path("longyearbyen_ddem")) glacier_outlines = gu.Vector(xdem.examples.get_path("longyearbyen_glacier_outlines")) -mask_glacier = glacier_outlines.create_mask(ddem) +mask_glacier = glacier_outlines.create_mask(dh) # %% # We remove values on unstable terrain -ddem.data[mask_glacier] = np.nan +dh.data[mask_glacier] = np.nan # %% # We use the reference DEM to derive terrain variables such as slope, aspect, curvature (see :ref:`sphx_glr_auto_examples_plot_terrain_attributes.py`) @@ -54,11 +54,12 @@ # We use :func:`xdem.spstats.nd_binning` to perform N-dimensional binning on all those terrain variables, with uniform # bin length divided by 30. We use the :ref:`spatial_stats_nmad` as a robust measure of `statistical dispersion `_. -df = xdem.spstats.nd_binning(values=ddem.data,list_var=[slope, aspect, planc, profc], - list_var_names=['slope','aspect','planc','profc'], - statistics=['count',np.nanmedian,xdem.spstats.nmad], - list_var_bins=30) +df = xdem.spatialstats.nd_binning(values=dh.data, list_var=[slope, aspect, planc, profc], + list_var_names=['slope','aspect','planc','profc'], + statistics=['count', xdem.spatialstats.nmad], + list_var_bins=30) +df # %% # We obtain a dataframe with the 1D binning results for each variable, the 2D binning results for all combinations of # variables and the N-D (here 4D) binning with all variables. @@ -66,7 +67,7 @@ # using :func:`xdem.spstats.plot_1d_binning`. # We can start with the slope that has been long known to be related to the elevation measurement error (e.g., # `Toutin (2002) `_). -xdem.spstats.plot_1d_binning(df, 'slope', 'nmad', 'Slope (degrees)', 'NMAD of dh (m)') +xdem.spatialstats.plot_1d_binning(df, 'slope', 'nmad', 'Slope (degrees)', 'NMAD of dh (m)') # %% # We identify a clear variability, with the dispersion estimated from the NMAD increasing from ~2 meters for nearly flat @@ -77,14 +78,14 @@ # # What about the aspect? -xdem.spstats.plot_1d_binning(df, 'aspect', 'nmad', 'Aspect (degrees)', 'NMAD of dh (m)') +xdem.spatialstats.plot_1d_binning(df, 'aspect', 'nmad', 'Aspect (degrees)', 'NMAD of dh (m)') # %% # There is no variability with the aspect which shows a dispersion averaging 2-3 meters, i.e. that of the complete sample. # # What about the plan curvature? -xdem.spstats.plot_1d_binning(df, 'planc', 'nmad', 'Planform curvature (100 m$^{-1}$)', 'NMAD of dh (m)') +xdem.spatialstats.plot_1d_binning(df, 'planc', 'nmad', 'Planform curvature (100 m$^{-1}$)', 'NMAD of dh (m)') # %% # The relation with the plan curvature remains ambiguous. @@ -95,20 +96,20 @@ # time and instead bin one at a time. # We define 1000 quantile bins of size 0.001 (equivalent to 0.1% percentile bins) for the profile curvature: -df = xdem.spstats.nd_binning(values=ddem.data,list_var=[profc], list_var_names=['profc'], - statistics=['count',np.nanmedian,xdem.spstats.nmad], - list_var_bins=[[np.nanquantile(profc,0.001*i) for i in range(1001)]]) -xdem.spstats.plot_1d_binning(df, 'profc', 'nmad', 'Profile curvature (100 m$^{-1}$)', 'NMAD of dh (m)') +df = xdem.spatialstats.nd_binning(values=dh.data, list_var=[profc], list_var_names=['profc'], + statistics=['count', np.nanmedian, xdem.spatialstats.nmad], + list_var_bins=[[np.nanquantile(profc,0.001*i) for i in range(1001)]]) +xdem.spatialstats.plot_1d_binning(df, 'profc', 'nmad', 'Profile curvature (100 m$^{-1}$)', 'NMAD of dh (m)') # %% # We now clearly identify the variability with the profile curvature, from 2 meters for low curvatures to above 4 meters # for higher positive or negative curvature. # What about the role of the plan curvature? -df = xdem.spstats.nd_binning(values=ddem.data,list_var=[planc], list_var_names=['planc'], - statistics=['count',np.nanmedian,xdem.spstats.nmad], - list_var_bins=[[np.nanquantile(planc,0.001*i) for i in range(1001)]]) -xdem.spstats.plot_1d_binning(df, 'planc', 'nmad', 'Planform curvature (100 m$^{-1}$)', 'NMAD of dh (m)') +df = xdem.spatialstats.nd_binning(values=dh.data, list_var=[planc], list_var_names=['planc'], + statistics=['count', np.nanmedian, xdem.spatialstats.nmad], + list_var_bins=[[np.nanquantile(planc,0.001*i) for i in range(1001)]]) +xdem.spatialstats.plot_1d_binning(df, 'planc', 'nmad', 'Planform curvature (100 m$^{-1}$)', 'NMAD of dh (m)') # %% # The plan curvature shows a similar relation. Those are symmetrical with 0, and almost equal for both types of curvature. @@ -116,10 +117,10 @@ # Derive maximum absolute curvature maxc = np.maximum(np.abs(planc),np.abs(profc)) -df = xdem.spstats.nd_binning(values=ddem.data,list_var=[maxc], list_var_names=['maxc'], - statistics=['count',np.nanmedian,xdem.spstats.nmad], - list_var_bins=[[np.nanquantile(maxc,0.002*i) for i in range(501)]]) -xdem.spstats.plot_1d_binning(df, 'maxc', 'nmad', 'Maximum absolute curvature (100 m$^{-1}$)', 'NMAD of dh (m)') +df = xdem.spatialstats.nd_binning(values=dh.data, list_var=[maxc], list_var_names=['maxc'], + statistics=['count', np.nanmedian, xdem.spatialstats.nmad], + list_var_bins=[[np.nanquantile(maxc,0.002*i) for i in range(501)]]) +xdem.spatialstats.plot_1d_binning(df, 'maxc', 'nmad', 'Maximum absolute curvature (100 m$^{-1}$)', 'NMAD of dh (m)') # %% # Here's our simplified relation! We now have both slope and maximum absolute curvature with clear variability of @@ -130,11 +131,11 @@ # # We need to explore the variability with both slope and curvature at the same time: -df = xdem.spstats.nd_binning(values=ddem.data,list_var=[slope, maxc], list_var_names=['slope','maxc'], - statistics=['count',np.nanmedian,xdem.spstats.nmad], - list_var_bins=30) +df = xdem.spatialstats.nd_binning(values=dh.data, list_var=[slope, maxc], list_var_names=['slope', 'maxc'], + statistics=['count', np.nanmedian, xdem.spatialstats.nmad], + list_var_bins=30) -xdem.spstats.plot_2d_binning(df, 'slope', 'maxc', 'nmad', 'Slope (degrees)', 'Maximum absolute curvature (100 m$^{-1}$)', 'NMAD of dh (m)') +xdem.spatialstats.plot_2d_binning(df, 'slope', 'maxc', 'nmad', 'Slope (degrees)', 'Maximum absolute curvature (100 m$^{-1}$)', 'NMAD of dh (m)') # %% # We can see that part of the variability seems to be independent, but with the uniform bins it is hard to tell much @@ -152,10 +153,10 @@ + [np.quantile(maxc,0.98 + 0.005*i) for i in range(3)] + [np.quantile(maxc,0.995 + 0.001*i) for i in range(6)]) -df = xdem.spstats.nd_binning(values=ddem.data,list_var=[slope, maxc], list_var_names=['slope','maxc'], - statistics=['count',np.nanmedian,xdem.spstats.nmad], - list_var_bins=[custom_bin_slope,custom_bin_curvature]) -xdem.spstats.plot_2d_binning(df, 'slope', 'maxc', 'nmad', 'Slope (degrees)', 'Maximum absolute curvature (100 m$^{-1}$)', 'NMAD of dh (m)', scale_var_2='log', vmin=2, vmax=10) +df = xdem.spatialstats.nd_binning(values=dh.data, list_var=[slope, maxc], list_var_names=['slope', 'maxc'], + statistics=['count', np.nanmedian, xdem.spatialstats.nmad], + list_var_bins=[custom_bin_slope,custom_bin_curvature]) +xdem.spatialstats.plot_2d_binning(df, 'slope', 'maxc', 'nmad', 'Slope (degrees)', 'Maximum absolute curvature (100 m$^{-1}$)', 'NMAD of dh (m)', scale_var_2='log', vmin=2, vmax=10) # %% @@ -171,7 +172,7 @@ # approximation i.e. a piecewise linear interpolation/extrapolation based on the binning results. # To ensure that only robust statistic values are used in the interpolation, we set a ``min_count`` value at 30 samples. -slope_curv_to_dh_err = xdem.spstats.interp_nd_binning(df,list_var_names=['slope','maxc'],statistic='nmad',min_count=30) +slope_curv_to_dh_err = xdem.spatialstats.interp_nd_binning(df, list_var_names=['slope', 'maxc'], statistic='nmad', min_count=30) # %% # The output is an interpolant function of slope and curvature that we can use to estimate the elevation measurement diff --git a/examples/plot_vgm_error.py b/examples/plot_vgm_error.py index e9b0cefd..f6a4e47c 100644 --- a/examples/plot_vgm_error.py +++ b/examples/plot_vgm_error.py @@ -61,7 +61,7 @@ # %% # We can see that the elevation difference is still polluted by unmasked glaciers, let's filter large outliers outside 4 NMAD -ddem.data[np.abs(ddem.data)>4*xdem.spstats.nmad(ddem.data)] = np.nan +ddem.data[np.abs(ddem.data) > 4 * xdem.spatialstats.nmad(ddem.data)] = np.nan # %% # Let's plot the elevation differences after filtering @@ -81,22 +81,22 @@ np.random.seed(42) # Sample empirical variogram -df = xdem.spstats.sample_multirange_empirical_variogram(dh=ddem.data, gsd=ddem.res[0], nsamp=2000, nrun=100, maxlag=20000) +df = xdem.spatialstats.sample_multirange_empirical_variogram(dh=ddem.data, gsd=ddem.res[0], nsamp=2000, nrun=100, maxlag=20000) # Plot empirical variogram -xdem.spstats.plot_vgm(df) -fun, _ = xdem.spstats.fit_model_sum_vgm(['Sph'], df) -fun2, _ = xdem.spstats.fit_model_sum_vgm(['Sph', 'Sph', 'Sph'], emp_vgm_df=df) +xdem.spatialstats.plot_vgm(df) +fun, _ = xdem.spatialstats.fit_model_sum_vgm(['Sph'], df) +fun2, _ = xdem.spatialstats.fit_model_sum_vgm(['Sph', 'Sph', 'Sph'], emp_vgm_df=df) # %% # We use :func:`xdem.spstats.nd_binning` to perform N-dimensional binning on all those terrain variables, with uniform # bin length divided by 30. We use the :ref:`spatial_stats_nmad` as a robust measure of `statistical dispersion `_. -df = xdem.spstats.nd_binning(values=ddem.data,list_var=[slope, aspect, planc, profc], - list_var_names=['slope','aspect','planc','profc'], - statistics=['count',np.nanmedian,xdem.spstats.nmad], - list_var_bins=30) +df = xdem.spatialstats.nd_binning(values=ddem.data, list_var=[slope, aspect, planc, profc], + list_var_names=['slope','aspect','planc','profc'], + statistics=['count', np.nanmedian, xdem.spatialstats.nmad], + list_var_bins=30) # %% # We obtain a dataframe with the 1D binning results for each variable, the 2D binning results for all combinations of @@ -105,7 +105,7 @@ # using :func:`xdem.spstats.plot_1d_binning`. # We can start with the slope that has been long known to be related to the elevation measurement error (e.g., # `Toutin (2002) `_). -xdem.spstats.plot_1d_binning(df, 'slope', 'nmad', 'Slope (degrees)', 'NMAD of dh (m)') +xdem.spatialstats.plot_1d_binning(df, 'slope', 'nmad', 'Slope (degrees)', 'NMAD of dh (m)') # %% # We identify a clear variability, with the dispersion estimated from the NMAD increasing from ~2 meters for nearly flat @@ -116,14 +116,14 @@ # # What about the aspect? -xdem.spstats.plot_1d_binning(df, 'aspect', 'nmad', 'Aspect (degrees)', 'NMAD of dh (m)') +xdem.spatialstats.plot_1d_binning(df, 'aspect', 'nmad', 'Aspect (degrees)', 'NMAD of dh (m)') # %% # There is no variability with the aspect which shows a dispersion averaging 2-3 meters, i.e. that of the complete sample. # # What about the plan curvature? -xdem.spstats.plot_1d_binning(df, 'planc', 'nmad', 'Planform curvature (100 m$^{-1}$)', 'NMAD of dh (m)') +xdem.spatialstats.plot_1d_binning(df, 'planc', 'nmad', 'Planform curvature (100 m$^{-1}$)', 'NMAD of dh (m)') # %% # The relation with the plan curvature remains ambiguous. @@ -134,20 +134,20 @@ # time and instead bin one at a time. # We define 1000 quantile bins of size 0.001 (equivalent to 0.1% percentile bins) for the profile curvature: -df = xdem.spstats.nd_binning(values=ddem.data,list_var=[profc], list_var_names=['profc'], - statistics=['count',np.nanmedian,xdem.spstats.nmad], - list_var_bins=[[np.nanquantile(profc,0.001*i) for i in range(1001)]]) -xdem.spstats.plot_1d_binning(df, 'profc', 'nmad', 'Profile curvature (100 m$^{-1}$)', 'NMAD of dh (m)') +df = xdem.spatialstats.nd_binning(values=ddem.data, list_var=[profc], list_var_names=['profc'], + statistics=['count', np.nanmedian, xdem.spatialstats.nmad], + list_var_bins=[[np.nanquantile(profc,0.001*i) for i in range(1001)]]) +xdem.spatialstats.plot_1d_binning(df, 'profc', 'nmad', 'Profile curvature (100 m$^{-1}$)', 'NMAD of dh (m)') # %% # We now clearly identify the variability with the profile curvature, from 2 meters for low curvatures to above 4 meters # for higher positive or negative curvature. # What about the role of the plan curvature? -df = xdem.spstats.nd_binning(values=ddem.data,list_var=[planc], list_var_names=['planc'], - statistics=['count',np.nanmedian,xdem.spstats.nmad], - list_var_bins=[[np.nanquantile(planc,0.001*i) for i in range(1001)]]) -xdem.spstats.plot_1d_binning(df, 'planc', 'nmad', 'Planform curvature (100 m$^{-1}$)', 'NMAD of dh (m)') +df = xdem.spatialstats.nd_binning(values=ddem.data, list_var=[planc], list_var_names=['planc'], + statistics=['count', np.nanmedian, xdem.spatialstats.nmad], + list_var_bins=[[np.nanquantile(planc,0.001*i) for i in range(1001)]]) +xdem.spatialstats.plot_1d_binning(df, 'planc', 'nmad', 'Planform curvature (100 m$^{-1}$)', 'NMAD of dh (m)') # %% # The plan curvature shows a similar relation. Those are symmetrical with 0, and almost equal for both types of curvature. @@ -155,10 +155,10 @@ # Derive maximum absolute curvature maxc = np.maximum(np.abs(planc),np.abs(profc)) -df = xdem.spstats.nd_binning(values=ddem.data,list_var=[maxc], list_var_names=['maxc'], - statistics=['count',np.nanmedian,xdem.spstats.nmad], - list_var_bins=[[np.nanquantile(maxc,0.002*i) for i in range(501)]]) -xdem.spstats.plot_1d_binning(df, 'maxc', 'nmad', 'Maximum absolute curvature (100 m$^{-1}$)', 'NMAD of dh (m)') +df = xdem.spatialstats.nd_binning(values=ddem.data, list_var=[maxc], list_var_names=['maxc'], + statistics=['count', np.nanmedian, xdem.spatialstats.nmad], + list_var_bins=[[np.nanquantile(maxc,0.002*i) for i in range(501)]]) +xdem.spatialstats.plot_1d_binning(df, 'maxc', 'nmad', 'Maximum absolute curvature (100 m$^{-1}$)', 'NMAD of dh (m)') # %% # Here's our simplified relation! We now have both slope and maximum absolute curvature with clear variability of @@ -169,11 +169,11 @@ # # We need to explore the variability with both slope and curvature at the same time: -df = xdem.spstats.nd_binning(values=ddem.data,list_var=[slope, maxc], list_var_names=['slope','maxc'], - statistics=['count',np.nanmedian,xdem.spstats.nmad], - list_var_bins=30) +df = xdem.spatialstats.nd_binning(values=ddem.data, list_var=[slope, maxc], list_var_names=['slope', 'maxc'], + statistics=['count', np.nanmedian, xdem.spatialstats.nmad], + list_var_bins=30) -xdem.spstats.plot_2d_binning(df, 'slope', 'maxc', 'nmad', 'Slope (degrees)', 'Maximum absolute curvature (100 m$^{-1}$)', 'NMAD of dh (m)') +xdem.spatialstats.plot_2d_binning(df, 'slope', 'maxc', 'nmad', 'Slope (degrees)', 'Maximum absolute curvature (100 m$^{-1}$)', 'NMAD of dh (m)') # %% # We can see that part of the variability seems to be independent, but with the uniform bins it is hard to tell much @@ -191,10 +191,10 @@ + [np.quantile(maxc,0.98 + 0.005*i) for i in range(3)] + [np.quantile(maxc,0.995 + 0.001*i) for i in range(6)]) -df = xdem.spstats.nd_binning(values=ddem.data,list_var=[slope, maxc], list_var_names=['slope','maxc'], - statistics=['count',np.nanmedian,xdem.spstats.nmad], - list_var_bins=[custom_bin_slope,custom_bin_curvature]) -xdem.spstats.plot_2d_binning(df, 'slope', 'maxc', 'nmad', 'Slope (degrees)', 'Maximum absolute curvature (100 m$^{-1}$)', 'NMAD of dh (m)', scale_var_2='log', vmin=2, vmax=10) +df = xdem.spatialstats.nd_binning(values=ddem.data, list_var=[slope, maxc], list_var_names=['slope', 'maxc'], + statistics=['count', np.nanmedian, xdem.spatialstats.nmad], + list_var_bins=[custom_bin_slope,custom_bin_curvature]) +xdem.spatialstats.plot_2d_binning(df, 'slope', 'maxc', 'nmad', 'Slope (degrees)', 'Maximum absolute curvature (100 m$^{-1}$)', 'NMAD of dh (m)', scale_var_2='log', vmin=2, vmax=10) # %% @@ -210,7 +210,7 @@ # approximation i.e. a piecewise linear interpolation/extrapolation based on the binning results. # To ensure that only robust statistic values are used in the interpolation, we set a ``min_count`` value at 30 samples. -slope_curv_to_dh_err = xdem.spstats.interp_nd_binning(df,list_var_names=['slope','maxc'],statistic='nmad',min_count=30) +slope_curv_to_dh_err = xdem.spatialstats.interp_nd_binning(df, list_var_names=['slope', 'maxc'], statistic='nmad', min_count=30) # %% # The output is an interpolant function of slope and curvature that we can use to estimate the elevation measurement diff --git a/xdem/__init__.py b/xdem/__init__.py index e07cd0a6..4249d117 100644 --- a/xdem/__init__.py +++ b/xdem/__init__.py @@ -1,4 +1,4 @@ -from . import coreg, dem, examples, spatial_tools, spstats, volume, filters, terrain +from . import coreg, dem, examples, spatial_tools, spatialstats, volume, filters, terrain from .ddem import dDEM from .dem import DEM from .demcollection import DEMCollection From e70d3606d6d5fa82ccbdfba41d3d5d07d76ab5f4 Mon Sep 17 00:00:00 2001 From: rhugonne Date: Fri, 16 Jul 2021 16:25:06 +0200 Subject: [PATCH 056/113] incremental commit on documentation --- docs/source/api.rst | 2 +- docs/source/biascorr.rst | 4 +- docs/source/filters.rst | 4 + docs/source/index.rst | 2 +- docs/source/intro.rst | 55 +++++---- docs/source/robuststats.rst | 114 +++++++++++-------- docs/source/spatialstats.rst | 8 +- examples/plot_nonstationary_error.py | 19 +++- examples/plot_vgm_error.py | 164 +-------------------------- xdem/spatialstats.py | 43 +++++-- 10 files changed, 170 insertions(+), 245 deletions(-) create mode 100644 docs/source/filters.rst diff --git a/docs/source/api.rst b/docs/source/api.rst index 1512c8c2..77400505 100644 --- a/docs/source/api.rst +++ b/docs/source/api.rst @@ -15,7 +15,7 @@ Full information about xdem's functionality is provided on this page. dem filters spatial_tools - spstats + spatialstats terrain volume diff --git a/docs/source/biascorr.rst b/docs/source/biascorr.rst index 77d684e3..21e8704c 100644 --- a/docs/source/biascorr.rst +++ b/docs/source/biascorr.rst @@ -6,11 +6,11 @@ Bias corrections Bias corrections correspond to transformations that cannot be described as a 3-dimensional affine function (see :ref:`coregistration`). Directional biases -****************** +------------------ TODO Terrain biases -************** +-------------- TODO \ No newline at end of file diff --git a/docs/source/filters.rst b/docs/source/filters.rst new file mode 100644 index 00000000..2e0411da --- /dev/null +++ b/docs/source/filters.rst @@ -0,0 +1,4 @@ +Filtering +========= + +In construction \ No newline at end of file diff --git a/docs/source/index.rst b/docs/source/index.rst index ecd56a19..7feaaac5 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -5,7 +5,7 @@ Welcome to xdem's documentation! ================================ -xdem aims to make Digital Elevation Model (DEM) analysis easy. +``xdem`` aims to make Digital Elevation Model (DEM) analysis easy. Coregistration, subtraction (and volume measurements), and error statistics should be available to anyone with the correct input data. diff --git a/docs/source/intro.rst b/docs/source/intro.rst index 2e9072f4..35973a61 100644 --- a/docs/source/intro.rst +++ b/docs/source/intro.rst @@ -3,7 +3,9 @@ Introduction: why is it complex to assess DEM accuracy and precision? ===================================================================== -Digital Elevation Models are numerical, gridded representations of elevation. They are generated from different instruments (e.g., optical sensors, radar, lidar), acquired in different conditions (e.g., ground, airborne, satellite), and using different post-processing techniques (e.g., photogrammetry, interferometry). +Digital Elevation Models are numerical, gridded representations of elevation. They are generated from different +instruments (e.g., optical sensors, radar, lidar), acquired in different conditions (e.g., ground, airborne, satellite) +, and using different post-processing techniques (e.g., photogrammetry, interferometry). While some complexities are specific to certain instruments and methods, all DEMs generally possess: @@ -11,14 +13,17 @@ While some complexities are specific to certain instruments and methods, all DEM - a `georeferencing `_ **that can be subject to shifts, tilts or other deformations** due to inherent instrument errors, noise, or associated processing schemes, - a large number of `outliers `_ **that remain difficult to filter** as they can originate from various sources (e.g., photogrammetric blunders, clouds). -These factors lead to difficulties in assessing the accuracy and precision of DEMs, which are necessary to perform further analysis. +These factors lead to difficulties in assessing the accuracy and precision of DEMs, which are necessary to perform +further analysis. -In ``xdem``, we provide a framework with state-of-the-art methods published in the scientific literature to make DEM calculations consistent, reproducible, and easy. +In ``xdem``, we provide a framework with state-of-the-art methods published in the scientific literature to make DEM +calculations consistent, reproducible, and easy. Accuracy and precision -********************** +---------------------- -Both `accuracy and precision `_ are important factors to account for when analyzing DEMs: +Both `accuracy and precision `_ are important factors to account +for when analyzing DEMs: - the **accuracy** (systematic error) of a DEM describes how close a DEM is to the true location of measured elevations on the Earth's surface, - the **precision** (random error) of a DEM describes the typical spread of its error in measurement, independently of a possible bias from the true positioning. @@ -26,10 +31,11 @@ Both `accuracy and precision `_, accessed 29.06.21. +Source: `antarcticglaciers.org `_, accessed 29.06.21. Absolute or relative accuracy -***************************** +----------------------------- The measure of accuracy can be further divided into two aspects: @@ -39,12 +45,13 @@ The measure of accuracy can be further divided into two aspects: TODO: Add another little schematic! Optimizing DEM absolute accuracy -********************************** +-------------------------------- Shifts due to poor absolute accuracy are common in elevation datasets, and can be easily corrected by performing a DEM -co-registration to precise and accurate, quality-controlled elevation data such as `ICESat `_ -and `ICESat-2 `_. -Quality-controlled DEMs aligned on high-accuracy data also exists, such as TanDEM-X global DEM (see `Rizzoli et al. (2017) `_) +co-registration to precise and accurate, quality-controlled elevation data such as `ICESat `_ and `ICESat-2 `_. +Quality-controlled DEMs aligned on high-accuracy data also exists, such as TanDEM-X global DEM (see `Rizzoli et al. +(2017) `_). Those biases can be corrected using the methods described in :ref:`coregistration`. @@ -52,26 +59,36 @@ Those biases can be corrected using the methods described in :ref:`coregistratio :add-heading: Optimizing DEM relative accuracy -********************************** +-------------------------------- -As the **absolute accuracy** can be corrected a posteriori using reference elevation datasets, many analyses only focus on **relative accuracy**, i.e. the remaining biases between several DEMs co-registered relative one to another. -By harnessing the denser, nearly continuous sampling of raster DEMs (in opposition to the sparser sampling of higher-accuracy point elevation data), one can identify and correct other types of biases: +As the **absolute accuracy** can be corrected a posteriori using reference elevation datasets, many analyses only focus +on **relative accuracy**, i.e. the remaining biases between several DEMs co-registered relative one to another. +By harnessing the denser, nearly continuous sampling of raster DEMs (in opposition to the sparser sampling of +higher-accuracy point elevation data), one can identify and correct other types of biases: - Terrain-related biases that can originate from the difference of resolution of DEMs, or instrument processing deformations (e.g., curvature-related biases described in `Gardelle et al. (2012) `_). - Directional biases that can be linked to instrument noise, such as along-track oscillations observed in many widepsread DEM products such as SRTM, ASTER, SPOT, Pléiades (e.g., `Girod et al. (2017) `_). -Those biases can be tackled by iteratively combining co-registration and bias-correction methods described in :ref:`coregistration` and :ref:`biascorr`. +Those biases can be tackled by iteratively combining co-registration and bias-correction methods described +in :ref:`coregistration` and :ref:`biascorr`. TODO: Add a plot on co-registration + bias correction between two DEMs Quantifying DEM precision -************************** +------------------------- -While dealing with **accuracy** is quite straightforward as it consists of minimizing the differences (biases) between several datasets, assessing the **precision** of DEMs can be much more complex. +While dealing with **accuracy** is quite straightforward as it consists of minimizing the differences (biases) between +several datasets, assessing the **precision** of DEMs can be much more complex. Measurement errors of a DEM cannot be quantified by a simple difference and require statistical inference. -The **precision** of DEMs has historically been reported by a single metric (e.g., precision of :math:`\pm` 2 m), but recent studies have shown the limitations of such simple metrics and provide more statistically-advanced methods to account for potential variabilities in precision and related correlations in space. -However, the lack of implementations of these methods in a modern programming language makes them hard to reproduce, validate, and apply consistently. This is why one of the main goals of ``xdem`` is to simplify state-of-the-art statistical measures, to allow accurate DEM uncertainty estimation for everyone. +The **precision** of DEMs has historically been reported by a single metric (e.g., precision of :math:`\pm` 2 m), but +recent studies (e.g., `Rolstad et al. (2009) `_, `Dehecq et al. (2020) `_ and `Hugonnet et al. (2021) `_) +have shown the limitations of such simple metrics and provide more statistically-advanced methods to account for +potential variabilities in precision and related correlations in space. +However, the lack of implementations of these methods in a modern programming language makes them hard to reproduce, +validate, and apply consistently. This is why one of the main goals of ``xdem`` is to simplify state-of-the-art +statistical measures, to allow accurate DEM uncertainty estimation for everyone. The tools for quantifying DEM precision are described in :ref:`spatialstats`. diff --git a/docs/source/robuststats.rst b/docs/source/robuststats.rst index 309b53b9..2cf5d138 100644 --- a/docs/source/robuststats.rst +++ b/docs/source/robuststats.rst @@ -2,69 +2,93 @@ Robust statistics ================== Digital Elevation Models often contain outliers that hamper further analysis. -In order to deal with outliers, ``xdem`` integrates statistical measures robust to outliers to be used for estimation of the -mean or dispersion of a sample, or more complex function fitting. +In order to deal with outliers, ``xdem`` integrates `robust statistics `_ +methods at different levels. +For instance, those can be used robustly fit functions necessary to perform alignment (:ref:`coreg`, :ref:`biascorr`), or to provide +robust statistical measures equivalent to the mean or standard deviation or the covariance of a sample when dealing with +:ref:`spatialstats`. + +The downside of robust statistical measures is that those can yield less precise estimates for small samples sizes and, +in some cases, hide patterns inherent to the data by smoothing. +As a consequence, when outliers exhibit idenfiable patterns, it is better to first resort to outlier filtering (:ref:`filters`) +and perform analysis using traditional statistical measures. .. contents:: Contents :local: -Common statistical measures -^^^^^^^^^^^^^^^^^^^^^^^^^^^ +Measures of central tendency and dispersion of a sample +-------------------------------------------------------- -Mean -**** -The mean of a dDEM is most often used to calculate the elevation change of terrain, for example of a glacier. -The mean elevation change can then be used to calculate the change in volume by multiplying with the associated area. -A considerable limitation of the mean of unfiltered data is that outliers may severely affect the result. -If you have 100 pixels that generally signify an elevation change of --30 m, but one pixel is a rogue NoData value of -9999, the mean elevation change will be --129.69 m! -If the mean is used, extreme outliers should first be accounted for. +``xdem`` -.. code-block:: python +Central tendency +^^^^^^^^^^^^^^^^ - mean = ddem.data.mean() +The `central tendency `_ represents the central value of a sample, +typically described by measures such as the `mean `, and is mostly useful during +analysis of sample accuracy (see :ref:`intro`). +However, the mean is a measure sensitive to outliers. In many cases, for example when working on unfiltered DEMs, using +the `median `_ is therefore preferable. -Median -****** -The median is the most common value in a distribution. -If the values are normally distributed (as a bell-curve), the median lies exactly on top of the curve. -Medians are often used as a more robust value to represent the centre of a distribution than the mean, as it is less affected by outliers. -Going with the same example as above, the median of the 100 pixels with one oulier would be --30 m. -The median is however not always suitable for e.g. volume change, as the value may not be perfectly representative at all times. -For example, the median of a DEM with integer elevations [100, 130, 140, 150, 160] would yield a median of 140 m, while an arguably better value for volume change would be the mean (136 m). +When working with weighted data, the `weighted median `_ can be used as +a robust measure of central tendency. -.. code-block:: python - - median = np.median(ddem.data) +The median is used by default alignment routines in :ref:`coreg` and :ref:`biascorr`. -Standard deviation -****************** -The standard deviation (STD) is often used to represent the spread of a distribution of values. -It is theoretically made to represent the spread of a perfect bell-curve, where an STD of ±1 represents 68.2% of all values. -Conversely ±2 STDs represent 95.2% of all values. +Dispersion +^^^^^^^^^^ + +The `statistical dispersion `_ represents the spread of a sample, +typically described by measures such as the `standard deviation `_, and +is a useful metric in the analysis of sample precision (see :ref:`intro`). +However, the standard deviation is a measure sensitive to outliers. The normalized median absolute deviation (NMAD), which +corresponds to the `median absolute deviation `_ scaled by a factor +of ~1.4826 to match the dispersion of a normal distribution, is the median equivalent of a standard deviation and has been shown to +provide more robust when working with DEMs (e.g., `Höhle and Höhle (2009) `_). + +When working with weighted data, the difference between the 84th and 16th `weighted percentile `_ can be used as a robust measure of dispersion. .. code-block:: python - - std = ddem.data.std() + nmad = xdem.spatial_tools.nmad(ddem.data) -RMSE -**** -The Root Mean Squared Error (RMSE) is a measure of the agreement of the values in a distribution. -It is highly sensitive to outliers, and is often used in photogrammetry where outliers can be detrimental to the relative, internal or external orientation of images. -RMSE's are however unsuitable for e.g. volume change error, as the purposefully exaggerated outliers will not have the same exaggerated effect on the mean. -.. code-block:: python +Measures of correlation +----------------------- - rmse = np.sqrt(np.mean(np.square(ddem.data))) +Correlation between samples +^^^^^^^^^^^^^^^^^^^^^^^^^^^ -.. _spatial_stats_nmad: +The `covariance `_ is the measure generally used to estimate the joint variability +of samples, often normalized to a `correlation coefficient `_. +Again, the variance and covariance are sensitive measures to outliers. It is therefore preferable to compute such measures +by filtering the data, or using robust estimators. -NMAD -**** -The Normalized Median Absolute Deviation (NMAD) is another measure of the spread of a distribution, similar to the RMSE and standard deviation. +Spatial auto-correlation of a sample +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -TODO: Add a rationale for this approach. +`Variogram `_ analysis exploits statistical measures equivalent to the covariance, +and is therefore also subject to outliers. +Based on `scikit-gstat `_, ``xdem`` allows to specify robust variogram +estimators such as Dowd's variogram based on medians (`Dowd (1984) `_). -.. code-block:: python +Regression analysis +------------------- + +``xdem`` encapsulates methods from scipy and sklearn to perform robust regression for :ref:`coreg` and :ref:`biascorr`. + +Robust loss functions +^^^^^^^^^^^^^^^^^^^^^ + +Based on `scipy.optimize `_ and specific `loss functions +`_, robust least-squares can be performed. + +Robust estimators +^^^^^^^^^^^^^^^^^ + +Based on `sklearn.linear_models `_, robust estimator such as `RANSAC `_, +`Theil-Sen `_, or the `Huber loss function `_ +are available for robust function fitting. - nmad = xdem.spatial_tools.nmad(ddem.data) diff --git a/docs/source/spatialstats.rst b/docs/source/spatialstats.rst index 140c859f..96013b6f 100644 --- a/docs/source/spatialstats.rst +++ b/docs/source/spatialstats.rst @@ -22,7 +22,7 @@ In particular, these tools help to: :local: Spatial statistics for DEM precision estimation -*********************************************** +----------------------------------------------- Assumptions for statistical inference in spatial statistics ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -68,12 +68,12 @@ Using stable terrain as a proxy When comparing elevation datasets, stable terrain is usually used a proxy Workflow for DEM precision estimation -************************************* +------------------------------------- Non-stationarity in elevation measurement errors ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -.. minigallery:: xdem.spstats.nd_binning +.. minigallery:: xdem.spatialstats.nd_binning :add-heading: Quantify and model non-stationarites @@ -123,7 +123,7 @@ TODO: Add this section based on Krige's relation (Webster & Oliver, 2007), Hugon Metrics for DEM precision -************************* +------------------------- Historically, the precision of DEMs has been reported as a single value indicating the random error at the scale of a single pixel, for example :math:`\pm 2` meters. diff --git a/examples/plot_nonstationary_error.py b/examples/plot_nonstationary_error.py index 3d330855..46544b47 100644 --- a/examples/plot_nonstationary_error.py +++ b/examples/plot_nonstationary_error.py @@ -12,8 +12,8 @@ Here, we show an example in which we identify terrain-related non-stationarities for a DEM difference of Longyearbyen glacier. We quantify those non-stationarities by `binning `_ robustly -in N-dimension using :func:`xdem.spstats.nd_binning` and applying a N-dimensional interpolation -:func:`xdem.spstats.interp_nd_binning` to estimate a numerical function of the measurement error and derive the spatial +in N-dimension using :func:`xdem.spatialstats.nd_binning` and applying a N-dimensional interpolation +:func:`xdem.spatialstats.interp_nd_binning` to estimate a numerical function of the measurement error and derive the spatial distribution of elevation measurement errors of the difference of DEMs. **Reference**: `Hugonnet et al. (2021) `_, applied to the terrain slope @@ -51,7 +51,7 @@ resolution=ref_dem.res) # %% -# We use :func:`xdem.spstats.nd_binning` to perform N-dimensional binning on all those terrain variables, with uniform +# We use :func:`xdem.spatialstats.nd_binning` to perform N-dimensional binning on all those terrain variables, with uniform # bin length divided by 30. We use the :ref:`spatial_stats_nmad` as a robust measure of `statistical dispersion `_. df = xdem.spatialstats.nd_binning(values=dh.data, list_var=[slope, aspect, planc, profc], @@ -59,12 +59,19 @@ statistics=['count', xdem.spatialstats.nmad], list_var_bins=30) -df # %% # We obtain a dataframe with the 1D binning results for each variable, the 2D binning results for all combinations of # variables and the N-D (here 4D) binning with all variables. +# Overview of the dataframe structure for the 1D binning: +df[df.nd == 1] + +# %% +# And for the 4D binning: +df[df.nd == 4] + +# %% # We can now visualize the results of the 1D binning of the computed NMAD of elevation differences with each variable -# using :func:`xdem.spstats.plot_1d_binning`. +# using :func:`xdem.spatialstats.plot_1d_binning`. # We can start with the slope that has been long known to be related to the elevation measurement error (e.g., # `Toutin (2002) `_). xdem.spatialstats.plot_1d_binning(df, 'slope', 'nmad', 'Slope (degrees)', 'NMAD of dh (m)') @@ -90,7 +97,7 @@ # %% # The relation with the plan curvature remains ambiguous. # We should better define our bins to avoid sampling bins with too many or too few samples. For this, we can partition -# the data in quantiles in :func:`xdem.spstats.nd_binning`. +# the data in quantiles in :func:`xdem.spatialstats.nd_binning`. # Note: we need a higher number of bins to work with quantiles and still resolve the edges of the distribution. Thus, as # with many dimensions the N dimensional bin size increases exponentially, we avoid binning all variables at the same # time and instead bin one at a time. diff --git a/examples/plot_vgm_error.py b/examples/plot_vgm_error.py index f6a4e47c..540e5bea 100644 --- a/examples/plot_vgm_error.py +++ b/examples/plot_vgm_error.py @@ -13,13 +13,13 @@ Quantifying the spatial correlation in elevation measurement errors is essential to integrate measurement errors over an area of interest (e.g, to estimate the error of a mean or sum of samples). Once the spatial correlations are quantified, - several methods exist the approximate the measurement error in space (`Rolstad et al. (2009) `_ - , Hugonnet et al. (in prep)). Further details are availale in :ref:`spatialstats`. +several methods exist the approximate the measurement error in space (`Rolstad et al. (2009) `_ +, Hugonnet et al. (in prep)). Further details are availale in :ref:`spatialstats`. Here, we show an example in which we estimate spatially integrated elevation measurement errors for a DEM difference of -Longyearbyen glacier. We first quantify the spatial correlations using :func:`xdem.spstats.sample_multirange_empirical_variogram` +Longyearbyen glacier. We first quantify the spatial correlations using :func:`xdem.spatialstats.sample_multirange_empirical_variogram` based on routines of `scikit-gstat `_. We then model the empirical variogram - using a sum of variogram models using :func:`xdem.spstats.fit_model_sum_vgm`. +using a sum of variogram models using :func:`xdem.spatialstats.fit_model_sum_vgm`. Finally, we integrate the variogram models for varying surface areas to estimate the spatially integrated elevation measurement errors for this DEM difference. @@ -78,10 +78,8 @@ plt.show() # %% -np.random.seed(42) - # Sample empirical variogram -df = xdem.spatialstats.sample_multirange_empirical_variogram(dh=ddem.data, gsd=ddem.res[0], nsamp=2000, nrun=100, maxlag=20000) +df = xdem.spatialstats.sample_multirange_variogram(dh=ddem.data, gsd=ddem.res[0], nsamp=2000, nrun=100, maxlag=20000) # Plot empirical variogram xdem.spatialstats.plot_vgm(df) @@ -89,156 +87,4 @@ fun2, _ = xdem.spatialstats.fit_model_sum_vgm(['Sph', 'Sph', 'Sph'], emp_vgm_df=df) -# %% -# We use :func:`xdem.spstats.nd_binning` to perform N-dimensional binning on all those terrain variables, with uniform -# bin length divided by 30. We use the :ref:`spatial_stats_nmad` as a robust measure of `statistical dispersion `_. - -df = xdem.spatialstats.nd_binning(values=ddem.data, list_var=[slope, aspect, planc, profc], - list_var_names=['slope','aspect','planc','profc'], - statistics=['count', np.nanmedian, xdem.spatialstats.nmad], - list_var_bins=30) - -# %% -# We obtain a dataframe with the 1D binning results for each variable, the 2D binning results for all combinations of -# variables and the N-D (here 4D) binning with all variables. -# We can now visualize the results of the 1D binning of the computed NMAD of elevation differences with each variable -# using :func:`xdem.spstats.plot_1d_binning`. -# We can start with the slope that has been long known to be related to the elevation measurement error (e.g., -# `Toutin (2002) `_). -xdem.spatialstats.plot_1d_binning(df, 'slope', 'nmad', 'Slope (degrees)', 'NMAD of dh (m)') - -# %% -# We identify a clear variability, with the dispersion estimated from the NMAD increasing from ~2 meters for nearly flat -# slopes to above 12 meters for slopes steeper than 50°. -# In statistical terms, such a variability of `variance `_ is referred as -# `heteroscedasticity `_. Here we observe heteroscedastic elevation -# differences due to a non-stationarity of variance with the terrain slope. -# -# What about the aspect? - -xdem.spatialstats.plot_1d_binning(df, 'aspect', 'nmad', 'Aspect (degrees)', 'NMAD of dh (m)') - -# %% -# There is no variability with the aspect which shows a dispersion averaging 2-3 meters, i.e. that of the complete sample. -# -# What about the plan curvature? - -xdem.spatialstats.plot_1d_binning(df, 'planc', 'nmad', 'Planform curvature (100 m$^{-1}$)', 'NMAD of dh (m)') - -# %% -# The relation with the plan curvature remains ambiguous. -# We should better define our bins to avoid sampling bins with too many or too few samples. For this, we can partition -# the data in quantiles in :func:`xdem.spstats.nd_binning`. -# Note: we need a higher number of bins to work with quantiles and still resolve the edges of the distribution. Thus, as -# with many dimensions the N dimensional bin size increases exponentially, we avoid binning all variables at the same -# time and instead bin one at a time. -# We define 1000 quantile bins of size 0.001 (equivalent to 0.1% percentile bins) for the profile curvature: - -df = xdem.spatialstats.nd_binning(values=ddem.data, list_var=[profc], list_var_names=['profc'], - statistics=['count', np.nanmedian, xdem.spatialstats.nmad], - list_var_bins=[[np.nanquantile(profc,0.001*i) for i in range(1001)]]) -xdem.spatialstats.plot_1d_binning(df, 'profc', 'nmad', 'Profile curvature (100 m$^{-1}$)', 'NMAD of dh (m)') - -# %% -# We now clearly identify the variability with the profile curvature, from 2 meters for low curvatures to above 4 meters -# for higher positive or negative curvature. -# What about the role of the plan curvature? - -df = xdem.spatialstats.nd_binning(values=ddem.data, list_var=[planc], list_var_names=['planc'], - statistics=['count', np.nanmedian, xdem.spatialstats.nmad], - list_var_bins=[[np.nanquantile(planc,0.001*i) for i in range(1001)]]) -xdem.spatialstats.plot_1d_binning(df, 'planc', 'nmad', 'Planform curvature (100 m$^{-1}$)', 'NMAD of dh (m)') - -# %% -# The plan curvature shows a similar relation. Those are symmetrical with 0, and almost equal for both types of curvature. -# To simplify the analysis, we here combine those curvatures into the maximum absolute curvature: - -# Derive maximum absolute curvature -maxc = np.maximum(np.abs(planc),np.abs(profc)) -df = xdem.spatialstats.nd_binning(values=ddem.data, list_var=[maxc], list_var_names=['maxc'], - statistics=['count', np.nanmedian, xdem.spatialstats.nmad], - list_var_bins=[[np.nanquantile(maxc,0.002*i) for i in range(501)]]) -xdem.spatialstats.plot_1d_binning(df, 'maxc', 'nmad', 'Maximum absolute curvature (100 m$^{-1}$)', 'NMAD of dh (m)') - -# %% -# Here's our simplified relation! We now have both slope and maximum absolute curvature with clear variability of -# the elevation measurement error. -# -# **But, one might wonder: high curvatures might occur more often around steep slopes than flat slope, -# so what if those two dependencies are actually one and the same?** -# -# We need to explore the variability with both slope and curvature at the same time: - -df = xdem.spatialstats.nd_binning(values=ddem.data, list_var=[slope, maxc], list_var_names=['slope', 'maxc'], - statistics=['count', np.nanmedian, xdem.spatialstats.nmad], - list_var_bins=30) - -xdem.spatialstats.plot_2d_binning(df, 'slope', 'maxc', 'nmad', 'Slope (degrees)', 'Maximum absolute curvature (100 m$^{-1}$)', 'NMAD of dh (m)') - -# %% -# We can see that part of the variability seems to be independent, but with the uniform bins it is hard to tell much -# more. -# -# If we use custom quantiles for both binning variables, and adjust the plot scale: - -custom_bin_slope = np.unique([np.quantile(slope,0.05*i) for i in range(20)] - + [np.quantile(slope,0.95+0.02*i) for i in range(2)] - + [np.quantile(slope,0.98 + 0.005*i) for i in range(3)] - + [np.quantile(slope,0.995 + 0.001*i) for i in range(6)]) - -custom_bin_curvature = np.unique([np.quantile(maxc,0.05*i) for i in range(20)] - + [np.quantile(maxc,0.95+0.02*i) for i in range(2)] - + [np.quantile(maxc,0.98 + 0.005*i) for i in range(3)] - + [np.quantile(maxc,0.995 + 0.001*i) for i in range(6)]) - -df = xdem.spatialstats.nd_binning(values=ddem.data, list_var=[slope, maxc], list_var_names=['slope', 'maxc'], - statistics=['count', np.nanmedian, xdem.spatialstats.nmad], - list_var_bins=[custom_bin_slope,custom_bin_curvature]) -xdem.spatialstats.plot_2d_binning(df, 'slope', 'maxc', 'nmad', 'Slope (degrees)', 'Maximum absolute curvature (100 m$^{-1}$)', 'NMAD of dh (m)', scale_var_2='log', vmin=2, vmax=10) - - -# %% -# We identify clearly that the two variables have an independent effect on the precision, with -# -# - *high curvatures and flat slopes* that have larger errors than *low curvatures and flat slopes* -# - *steep slopes and low curvatures* that have larger errors than *low curvatures and flat slopes* as well -# -# We also identify that, steep slopes (> 40°) only correspond to high curvature, while the opposite is not true, hence -# the importance of mapping the variability in two dimensions. -# -# Now we need to account for the non-stationarities identified. For this, the simplest approach is a numerical -# approximation i.e. a piecewise linear interpolation/extrapolation based on the binning results. -# To ensure that only robust statistic values are used in the interpolation, we set a ``min_count`` value at 30 samples. - -slope_curv_to_dh_err = xdem.spatialstats.interp_nd_binning(df, list_var_names=['slope', 'maxc'], statistic='nmad', min_count=30) - -# %% -# The output is an interpolant function of slope and curvature that we can use to estimate the elevation measurement -# error at any point. -# -# For instance: - -for s, c in [(0.,0.1), (50.,0.1), (0.,20.), (50.,20.)]: - print('Elevation measurement error for slope of {0:.0f} degrees, ' - 'curvature of {1:.2f} m-1: {2:.1f}'.format(s, c/100, slope_curv_to_dh_err((s,c)))+ ' meters.') - -# %% -# The same function can be used to estimate the spatial distribution of the elevation measurement error over the area: -dh_err = slope_curv_to_dh_err((slope, maxc)) - -plt.figure(figsize=(8, 5)) -plt_extent = [ - ref_dem.bounds.left, - ref_dem.bounds.right, - ref_dem.bounds.bottom, - ref_dem.bounds.top, -] -plt.imshow(dh_err.squeeze(), cmap="Reds", vmin=2, vmax=8, extent=plt_extent) -cbar = plt.colorbar() -cbar.set_label('Elevation measurement error (m)') -plt.show() - - - - diff --git a/xdem/spatialstats.py b/xdem/spatialstats.py index d4a4257b..b3318987 100644 --- a/xdem/spatialstats.py +++ b/xdem/spatialstats.py @@ -11,6 +11,7 @@ from typing import Callable, Union, Iterable, Optional, Sequence, Any import itertools +import matplotlib import matplotlib.pyplot as plt import matplotlib.colors as colors from numba import njit @@ -1034,7 +1035,7 @@ def patches_method(values : np.ndarray, mask: np.ndarray[bool], gsd : float, are def plot_vgm(df: pd.DataFrame, list_fit_fun: Optional[list[Callable[[float],float]]] = None, - list_fit_fun_label: Optional[list[str]] = None): + list_fit_fun_label: Optional[list[str]] = None, ax: matplotlib.axes.Axes | None = None): """ Plot empirical variogram, with optionally one or several model fits. Input dataframe is expected to be the output of xdem.spatialstats.sample_multirange_variogram. @@ -1043,10 +1044,19 @@ def plot_vgm(df: pd.DataFrame, list_fit_fun: Optional[list[Callable[[float],floa :param df: dataframe of empirical variogram :param list_fit_fun: list of model function fits :param list_fit_fun_label: list of model function fits labels - :param + :param ax: plotting ax to use, creates a new one by default :return: """ - fig, ax = plt.subplots(1) + + # Create axes + if ax is None: + fig, ax = plt.subplots() + elif isinstance(ax, matplotlib.axes.Axes): + ax = ax + fig = ax.figure + else: + raise ValueError("ax must be a matplotlib.axes.Axes instance or None") + if np.all(np.isnan(df.exp_sigma)): ax.scatter(df.bins, df.exp, label='Empirical variogram', color='blue') else: @@ -1072,7 +1082,7 @@ def plot_vgm(df: pd.DataFrame, list_fit_fun: Optional[list[Callable[[float],floa return ax def plot_1d_binning(df: pd.DataFrame, var_name: str, statistic_name: str, label_var: Optional[str] = None, - label_statistic: Optional[str] = None, min_count: int = 30): + label_statistic: Optional[str] = None, min_count: int = 30, ax: matplotlib.axes.Axes | None = None): """ Plot a statistic and its count along a single binning variable. Input is expected to be formatted as the output of the xdem.spatialstats.nd_binning function. @@ -1083,8 +1093,18 @@ def plot_1d_binning(df: pd.DataFrame, var_name: str, statistic_name: str, label_ :param label_var: label of binning variable :param label_statistic: label of statistic of interest :param min_count: removes statistic values computed with a count inferior to this minimum value + :param ax: plotting ax to use, creates a new one by default """ + # Create axes + if ax is None: + fig = plt.figure() + elif isinstance(ax, matplotlib.axes.Axes): + ax = ax + fig = ax.figure + else: + raise ValueError("ax must be a matplotlib.axes.Axes instance or None") + if label_var is None: label_var = var_name if label_statistic is None: @@ -1096,7 +1116,6 @@ def plot_1d_binning(df: pd.DataFrame, var_name: str, statistic_name: str, label_ df_sub.loc[df_sub['count'] Date: Fri, 16 Jul 2021 16:33:38 +0200 Subject: [PATCH 057/113] incremental commit on documentation --- docs/source/robuststats.rst | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/docs/source/robuststats.rst b/docs/source/robuststats.rst index 2cf5d138..09ee97a0 100644 --- a/docs/source/robuststats.rst +++ b/docs/source/robuststats.rst @@ -5,7 +5,7 @@ Digital Elevation Models often contain outliers that hamper further analysis. In order to deal with outliers, ``xdem`` integrates `robust statistics `_ methods at different levels. For instance, those can be used robustly fit functions necessary to perform alignment (:ref:`coreg`, :ref:`biascorr`), or to provide -robust statistical measures equivalent to the mean or standard deviation or the covariance of a sample when dealing with +robust statistical measures equivalent to the mean, the standard deviation or the covariance of a sample when dealing with :ref:`spatialstats`. The downside of robust statistical measures is that those can yield less precise estimates for small samples sizes and, @@ -19,8 +19,6 @@ and perform analysis using traditional statistical measures. Measures of central tendency and dispersion of a sample -------------------------------------------------------- -``xdem`` - Central tendency ^^^^^^^^^^^^^^^^ @@ -30,8 +28,9 @@ analysis of sample accuracy (see :ref:`intro`). However, the mean is a measure sensitive to outliers. In many cases, for example when working on unfiltered DEMs, using the `median `_ is therefore preferable. -When working with weighted data, the `weighted median `_ can be used as -a robust measure of central tendency. +When working with weighted data, the `weighted median `_ which corresponds +to the 50\ :sup:`th` `weighted percentile `_, can also be +used as a robust measure of central tendency. The median is used by default alignment routines in :ref:`coreg` and :ref:`biascorr`. @@ -45,14 +44,14 @@ However, the standard deviation is a measure sensitive to outliers. The normaliz corresponds to the `median absolute deviation `_ scaled by a factor of ~1.4826 to match the dispersion of a normal distribution, is the median equivalent of a standard deviation and has been shown to provide more robust when working with DEMs (e.g., `Höhle and Höhle (2009) `_). - -When working with weighted data, the difference between the 84th and 16th `weighted percentile `_ can be used as a robust measure of dispersion. +The half difference between 84\ :sup:`th` and 16\ :sup:`th` percentiles, or the absolute 68\ :sup:`th` percentile +can also be used as a robust dispersion measure equivalent to the standard deviation. .. code-block:: python - nmad = xdem.spatial_tools.nmad(ddem.data) +When working with weighted data, the difference between the 84th and 16th `weighted percentile `_, or the absolute 68\ :sup:`th` weighted percentile can be used as a robust measure of dispersion. Measures of correlation ----------------------- @@ -65,6 +64,8 @@ of samples, often normalized to a `correlation coefficient Date: Fri, 16 Jul 2021 17:13:16 +0200 Subject: [PATCH 058/113] incremental commit on documentation --- docs/source/filters.rst | 2 ++ docs/source/index.rst | 1 + docs/source/robuststats.rst | 31 ++++++++++++++++++++++--------- 3 files changed, 25 insertions(+), 9 deletions(-) diff --git a/docs/source/filters.rst b/docs/source/filters.rst index 2e0411da..09e2ce17 100644 --- a/docs/source/filters.rst +++ b/docs/source/filters.rst @@ -1,3 +1,5 @@ +.. _filters: + Filtering ========= diff --git a/docs/source/index.rst b/docs/source/index.rst index 7feaaac5..91d8aa27 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -30,6 +30,7 @@ Simple usage intro coregistration biascorr + filters comparison spatialstats robuststats diff --git a/docs/source/robuststats.rst b/docs/source/robuststats.rst index 09ee97a0..8f994837 100644 --- a/docs/source/robuststats.rst +++ b/docs/source/robuststats.rst @@ -1,16 +1,18 @@ +.. _robuststats: + Robust statistics ================== Digital Elevation Models often contain outliers that hamper further analysis. In order to deal with outliers, ``xdem`` integrates `robust statistics `_ methods at different levels. -For instance, those can be used robustly fit functions necessary to perform alignment (:ref:`coreg`, :ref:`biascorr`), or to provide +For instance, those can be used robustly fit functions necessary to perform alignment (see :ref:`coregistration`, :ref:`biascorr`), or to provide robust statistical measures equivalent to the mean, the standard deviation or the covariance of a sample when dealing with :ref:`spatialstats`. The downside of robust statistical measures is that those can yield less precise estimates for small samples sizes and, in some cases, hide patterns inherent to the data by smoothing. -As a consequence, when outliers exhibit idenfiable patterns, it is better to first resort to outlier filtering (:ref:`filters`) +As a consequence, when outliers exhibit idenfiable patterns, it is better to first resort to outlier filtering (see :ref:`filters`) and perform analysis using traditional statistical measures. .. contents:: Contents @@ -23,7 +25,7 @@ Central tendency ^^^^^^^^^^^^^^^^ The `central tendency `_ represents the central value of a sample, -typically described by measures such as the `mean `, and is mostly useful during +typically described by measures such as the `mean `_, and is mostly useful during analysis of sample accuracy (see :ref:`intro`). However, the mean is a measure sensitive to outliers. In many cases, for example when working on unfiltered DEMs, using the `median `_ is therefore preferable. @@ -32,7 +34,7 @@ When working with weighted data, the `weighted median `_, can also be used as a robust measure of central tendency. -The median is used by default alignment routines in :ref:`coreg` and :ref:`biascorr`. +The median is used by default alignment routines in :ref:`coregistration` and :ref:`biascorr`. Dispersion ^^^^^^^^^^ @@ -44,13 +46,20 @@ However, the standard deviation is a measure sensitive to outliers. The normaliz corresponds to the `median absolute deviation `_ scaled by a factor of ~1.4826 to match the dispersion of a normal distribution, is the median equivalent of a standard deviation and has been shown to provide more robust when working with DEMs (e.g., `Höhle and Höhle (2009) `_). +It is thus defined as: + +.. math:: + NMAD_{x} = 1.4826 \cdot \textrm{median}_{i} \left ( \mid x_{i} - \textrm{median}(x) \mid \right ) + +where :math:`x` is the sample. + The half difference between 84\ :sup:`th` and 16\ :sup:`th` percentiles, or the absolute 68\ :sup:`th` percentile can also be used as a robust dispersion measure equivalent to the standard deviation. .. code-block:: python - nmad = xdem.spatial_tools.nmad(ddem.data) + nmad = xdem.spatial_tools.nmad() -When working with weighted data, the difference between the 84th and 16th `weighted percentile `_, or the absolute 68\ :sup:`th` weighted percentile can be used as a robust measure of dispersion. Measures of correlation @@ -72,13 +81,17 @@ Spatial auto-correlation of a sample `Variogram `_ analysis exploits statistical measures equivalent to the covariance, and is therefore also subject to outliers. Based on `scikit-gstat `_, ``xdem`` allows to specify robust variogram -estimators such as Dowd's variogram based on medians (`Dowd (1984) `_). +estimators such as Dowd's variogram based on medians, see `Dowd (1984) `_. +It is defined as: + +.. math:: + 2\gamma (h) = 2.198 \cdot \textrm{median}_{i} \left ( Z_{x_{i}} - Z_{x_{i+h}} \right ) + +where :math:`h` is the spatial lag and :math:`Z_{x_{i}}` is the value of the sample at the location :math:`x_{i}`. Regression analysis ------------------- -``xdem`` encapsulates methods from scipy and sklearn to perform robust regression for :ref:`coreg` and :ref:`biascorr`. - Robust loss functions ^^^^^^^^^^^^^^^^^^^^^ From c0c5f37f793ec361e95a7d40a9722c113d6d8f61 Mon Sep 17 00:00:00 2001 From: rhugonne Date: Fri, 16 Jul 2021 18:18:29 +0200 Subject: [PATCH 059/113] incremental commit on documentation --- docs/source/robuststats.rst | 40 +++++++++++++++++++++++-------------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/docs/source/robuststats.rst b/docs/source/robuststats.rst index 8f994837..f088dfb8 100644 --- a/docs/source/robuststats.rst +++ b/docs/source/robuststats.rst @@ -24,14 +24,14 @@ Measures of central tendency and dispersion of a sample Central tendency ^^^^^^^^^^^^^^^^ -The `central tendency `_ represents the central value of a sample, -typically described by measures such as the `mean `_, and is mostly useful during +The `central tendency `_ represents the central value of a sample, and is +typically described by the `mean `_. Estimating central tendency is core to the analysis of sample accuracy (see :ref:`intro`). -However, the mean is a measure sensitive to outliers. In many cases, for example when working on unfiltered DEMs, using -the `median `_ is therefore preferable. +However, the mean is a measure sensitive to outliers. Therefore, in many cases, for example when working with unfiltered +DEMs, using the `median `_ as measure of central tendency is preferred. When working with weighted data, the `weighted median `_ which corresponds -to the 50\ :sup:`th` `weighted percentile `_, can also be +to the 50\ :sup:`th` `weighted percentile `_, can be used as a robust measure of central tendency. The median is used by default alignment routines in :ref:`coregistration` and :ref:`biascorr`. @@ -45,11 +45,11 @@ is a useful metric in the analysis of sample precision (see :ref:`intro`). However, the standard deviation is a measure sensitive to outliers. The normalized median absolute deviation (NMAD), which corresponds to the `median absolute deviation `_ scaled by a factor of ~1.4826 to match the dispersion of a normal distribution, is the median equivalent of a standard deviation and has been shown to -provide more robust when working with DEMs (e.g., `Höhle and Höhle (2009) `_). +provide more robust measures when working with DEMs (e.g., `Höhle and Höhle (2009) `_). It is thus defined as: .. math:: - NMAD_{x} = 1.4826 \cdot \textrm{median}_{i} \left ( \mid x_{i} - \textrm{median}(x) \mid \right ) + \textrm{NMAD}(x) = 1.4826 \cdot \textrm{median}_{i} \left ( \mid x_{i} - \textrm{median}(x) \mid \right ) where :math:`x` is the sample. @@ -62,6 +62,8 @@ can also be used as a robust dispersion measure equivalent to the standard devia When working with weighted data, the difference between the 84\ :sup:`th` and 16\ :sup:`th` `weighted percentile `_, or the absolute 68\ :sup:`th` weighted percentile can be used as a robust measure of dispersion. +The NMAD is used by default for estimating elevation measurement errors in :ref:`spatialstats`. + Measures of correlation ----------------------- @@ -89,20 +91,28 @@ It is defined as: where :math:`h` is the spatial lag and :math:`Z_{x_{i}}` is the value of the sample at the location :math:`x_{i}`. +Dowd's variogram is used by default to estimate spatial auto-correlation of elevation measurement errors in :ref:`spatialstats`. + Regression analysis ------------------- -Robust loss functions -^^^^^^^^^^^^^^^^^^^^^ +Least-square loss functions +^^^^^^^^^^^^^^^^^^^^^^^^^^^ -Based on `scipy.optimize `_ and specific `loss functions -`_, robust least-squares can be performed. +When performing least-squares linear regression, the traditional `loss functions `_ that are used are not robust to outliers. + +By default, in :ref:`coregistration` and :ref:`biascorr`, ``xdem`` uses a robust soft L1 loss function with least-squares +of `scipy.optimize `_. Robust estimators ^^^^^^^^^^^^^^^^^ -Based on `sklearn.linear_models `_, robust estimator such as `RANSAC `_, -`Theil-Sen `_, or the `Huber loss function `_ -are available for robust function fitting. +Other estimators than ordinary least-squares can be used for linear estimations. +The :ref:`coregistration` and :ref:`biascorr` methods encapsulate some of those methods provided by `sklearn.linear_models +`_: + +- The Random sample consensus estimator `RANSAC `_, +- The `Theil-Sen `_ estimator, +- The `Huber loss `_ estimator. From 51a50b00a95848cb3375fb8df208da911dc02c2c Mon Sep 17 00:00:00 2001 From: rhugonne Date: Sat, 17 Jul 2021 17:38:14 +0200 Subject: [PATCH 060/113] incremental commit on documentation --- docs/source/robuststats.rst | 37 ++++++++++++++++++------------------- 1 file changed, 18 insertions(+), 19 deletions(-) diff --git a/docs/source/robuststats.rst b/docs/source/robuststats.rst index f088dfb8..a42918b7 100644 --- a/docs/source/robuststats.rst +++ b/docs/source/robuststats.rst @@ -4,16 +4,15 @@ Robust statistics ================== Digital Elevation Models often contain outliers that hamper further analysis. -In order to deal with outliers, ``xdem`` integrates `robust statistics `_ +In order to mitigate their effect on DEM analysis, ``xdem`` integrates `robust statistics `_ methods at different levels. -For instance, those can be used robustly fit functions necessary to perform alignment (see :ref:`coregistration`, :ref:`biascorr`), or to provide -robust statistical measures equivalent to the mean, the standard deviation or the covariance of a sample when dealing with +These methods can be used to robustly fit functions necessary to perform DEM alignment (see :ref:`coregistration`, :ref:`biascorr`), or to provide +robust statistical measures equivalent to the mean, the standard deviation or the covariance of a sample when analyzing DEM precision with :ref:`spatialstats`. -The downside of robust statistical measures is that those can yield less precise estimates for small samples sizes and, -in some cases, hide patterns inherent to the data by smoothing. -As a consequence, when outliers exhibit idenfiable patterns, it is better to first resort to outlier filtering (see :ref:`filters`) -and perform analysis using traditional statistical measures. +Yet, there is a downside to robust statistical measures. Those can yield less precise estimates for small samples sizes and, +in some cases, hide patterns inherent to the data. This is why, when outliers exhibit idenfiable patterns, it is better +to first resort to outlier filtering (see :ref:`filters`) and perform analysis using traditional statistical measures. .. contents:: Contents :local: @@ -25,28 +24,28 @@ Central tendency ^^^^^^^^^^^^^^^^ The `central tendency `_ represents the central value of a sample, and is -typically described by the `mean `_. Estimating central tendency is core to the -analysis of sample accuracy (see :ref:`intro`). -However, the mean is a measure sensitive to outliers. Therefore, in many cases, for example when working with unfiltered -DEMs, using the `median `_ as measure of central tendency is preferred. +core to the analysis of sample accuracy (see :ref:`intro`). It is most often measured by the `mean `_. +However, the mean is a measure sensitive to outliers. Therefore, in many cases (e.g., when working with unfiltered +DEMs) using the `median `_ as measure of central tendency is preferred. When working with weighted data, the `weighted median `_ which corresponds to the 50\ :sup:`th` `weighted percentile `_, can be used as a robust measure of central tendency. -The median is used by default alignment routines in :ref:`coregistration` and :ref:`biascorr`. +The median is used by default in the alignment routines of :ref:`coregistration` and :ref:`biascorr`. Dispersion ^^^^^^^^^^ The `statistical dispersion `_ represents the spread of a sample, -typically described by measures such as the `standard deviation `_, and -is a useful metric in the analysis of sample precision (see :ref:`intro`). -However, the standard deviation is a measure sensitive to outliers. The normalized median absolute deviation (NMAD), which -corresponds to the `median absolute deviation `_ scaled by a factor -of ~1.4826 to match the dispersion of a normal distribution, is the median equivalent of a standard deviation and has been shown to -provide more robust measures when working with DEMs (e.g., `Höhle and Höhle (2009) `_). -It is thus defined as: +and is core to the analysis of sample precision (see :ref:`intro`). It is typically measured by the `standard deviation +`_. +However, very much like the mean, the standard deviation is a measure sensitive to outliers. The median equivalent of a +standard deviation is the normalized median absolute deviation (NMAD), which corresponds to the `median absolute deviation + `_ scaled by a factor of ~1.4826 to match the dispersion of a +normal distribution. It has been shown to provide more robust measures of dispersion with outliers when working +with DEMs (e.g., `Höhle and Höhle (2009) `_). +It is defined as: .. math:: \textrm{NMAD}(x) = 1.4826 \cdot \textrm{median}_{i} \left ( \mid x_{i} - \textrm{median}(x) \mid \right ) From ba56f722360d8c3832ed23f1c8797f93d9a8e0da Mon Sep 17 00:00:00 2001 From: rhugonne Date: Sat, 17 Jul 2021 17:40:31 +0200 Subject: [PATCH 061/113] incremental commit on documentation --- docs/source/robuststats.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/source/robuststats.rst b/docs/source/robuststats.rst index a42918b7..71e70422 100644 --- a/docs/source/robuststats.rst +++ b/docs/source/robuststats.rst @@ -101,14 +101,14 @@ Least-square loss functions When performing least-squares linear regression, the traditional `loss functions `_ that are used are not robust to outliers. -By default, in :ref:`coregistration` and :ref:`biascorr`, ``xdem`` uses a robust soft L1 loss function with least-squares -of `scipy.optimize `_. +A robust soft L1 loss default is used by default when ``xdem`` uses least-squares regression through `scipy.optimize +`_. Robust estimators ^^^^^^^^^^^^^^^^^ Other estimators than ordinary least-squares can be used for linear estimations. -The :ref:`coregistration` and :ref:`biascorr` methods encapsulate some of those methods provided by `sklearn.linear_models +The :ref:`coregistration` and :ref:`biascorr` methods encapsulate some of those robust methods provided by `sklearn.linear_models `_: - The Random sample consensus estimator `RANSAC `_, From 81b6e6e699984f3171039bfa02dc4f3d00f87d08 Mon Sep 17 00:00:00 2001 From: rhugonne Date: Tue, 20 Jul 2021 15:48:25 +0200 Subject: [PATCH 062/113] fix spatial variogram function with new metricspace --- docs/source/robuststats.rst | 9 +- xdem/spatialstats.py | 267 ++++++++++++++++++++++-------------- 2 files changed, 167 insertions(+), 109 deletions(-) diff --git a/docs/source/robuststats.rst b/docs/source/robuststats.rst index 71e70422..9cadc276 100644 --- a/docs/source/robuststats.rst +++ b/docs/source/robuststats.rst @@ -29,7 +29,7 @@ However, the mean is a measure sensitive to outliers. Therefore, in many cases ( DEMs) using the `median `_ as measure of central tendency is preferred. When working with weighted data, the `weighted median `_ which corresponds -to the 50\ :sup:`th` `weighted percentile `_, can be +to the 50\ :sup:`th` `weighted percentile `_ can be used as a robust measure of central tendency. The median is used by default in the alignment routines of :ref:`coregistration` and :ref:`biascorr`. @@ -42,7 +42,7 @@ and is core to the analysis of sample precision (see :ref:`intro`). It is typica `_. However, very much like the mean, the standard deviation is a measure sensitive to outliers. The median equivalent of a standard deviation is the normalized median absolute deviation (NMAD), which corresponds to the `median absolute deviation - `_ scaled by a factor of ~1.4826 to match the dispersion of a +`_ scaled by a factor of ~1.4826 to match the dispersion of a normal distribution. It has been shown to provide more robust measures of dispersion with outliers when working with DEMs (e.g., `Höhle and Höhle (2009) `_). It is defined as: @@ -82,8 +82,7 @@ Spatial auto-correlation of a sample `Variogram `_ analysis exploits statistical measures equivalent to the covariance, and is therefore also subject to outliers. Based on `scikit-gstat `_, ``xdem`` allows to specify robust variogram -estimators such as Dowd's variogram based on medians, see `Dowd (1984) `_. -It is defined as: +estimators such as Dowd's variogram based on medians (`Dowd (1984) `_) defined as: .. math:: 2\gamma (h) = 2.198 \cdot \textrm{median}_{i} \left ( Z_{x_{i}} - Z_{x_{i+h}} \right ) @@ -101,7 +100,7 @@ Least-square loss functions When performing least-squares linear regression, the traditional `loss functions `_ that are used are not robust to outliers. -A robust soft L1 loss default is used by default when ``xdem`` uses least-squares regression through `scipy.optimize +A robust soft L1 loss default is used by default when ``xdem`` performs least-squares regression through `scipy.optimize `_. Robust estimators diff --git a/xdem/spatialstats.py b/xdem/spatialstats.py index b3318987..1543631a 100644 --- a/xdem/spatialstats.py +++ b/xdem/spatialstats.py @@ -251,15 +251,16 @@ def nd_binning(values: np.ndarray, list_var: Iterable[np.ndarray], list_var_name return df_concat -def get_empirical_variogram(values: np.ndarray, coords: np.ndarray, **kwargs) -> pd.DataFrame: +def _get_pdist_empirical_variogram(values: np.ndarray, coords: np.ndarray, **kwargs) -> pd.DataFrame: """ - Get empirical variogram from skgstat.Variogram object + Get empirical variogram from skgstat.Variogram object calculating pairwise distances within the sample :param values: values :param coords: coordinates :return: empirical variogram (variance, lags, counts) """ + V = skg.Variogram(coordinates=coords, values=values, normalize=False, fit_method = None, **kwargs) # To derive the middle of the bins @@ -272,9 +273,36 @@ def get_empirical_variogram(values: np.ndarray, coords: np.ndarray, **kwargs) -> return df -def wrapper_get_empirical_variogram(argdict: dict, **kwargs) -> pd.DataFrame: +def _get_cdist_empirical_variogram(values: np.ndarray, coords: np.ndarray, cdist_method: str, **kwargs) -> pd.DataFrame: """ - Multiprocessing wrapper for get_empirical_variogram + Get empirical variogram from skgstat.Variogram object calculating pairwise distances between two sample collections + of a MetricSpace (see scikit-gstat documentation for more details) + + :param values: values + :param coords: coordinates + :return: empirical variogram (variance, lags, counts) + + """ + if cdist_method == 'cdist_point': + ms = skg.ProbabalisticMetricSpace(coords=coords, samples=samples) + elif cdist_method == 'cdist_equidistant': + ms = skg.RasterEquidistantMetricSpace(coords=coords, shape=shape, extent=extent) + + V = skg.Variogram(ms, values=values, normalize=False, fit_method=None, **kwargs) + + # To derive the middle of the bins + bins, exp = V.get_empirical() + count = V.bin_count + + df = pd.DataFrame() + df = df.assign(exp=exp, bins=bins, count=count) + + return df + + +def _wrapper_get_pdist_empirical_variogram(argdict: dict, **kwargs) -> pd.DataFrame: + """ + Multiprocessing wrapper for get_pdist_empirical_variogram :param argdict: Keyword argument to pass to get_empirical_variogram() @@ -283,7 +311,7 @@ def wrapper_get_empirical_variogram(argdict: dict, **kwargs) -> pd.DataFrame: """ print('Working on subsample '+str(argdict['i']) + ' out of '+str(argdict['max_i'])) - return get_empirical_variogram(values=argdict['values'], coords=argdict['coords'], **kwargs) + return _get_pdist_empirical_variogram(values=argdict['values'], coords=argdict['coords'], **kwargs) def create_circular_mask(shape: Union[int, Sequence[int]], center: Optional[list[float]] = None, radius: Optional[float] = None) -> np.ndarray: @@ -341,14 +369,15 @@ def create_ring_mask(shape: Union[int, Sequence[int]], center: Optional[list[flo def _subsample_wrapper(values: np.ndarray, coords: np.ndarray, shape: tuple[int,int] = None, subsample: int = 10000, - subsample_method: str = 'random_ring', inside_radius = None, outside_radius = None) -> tuple[np.ndarray, np.ndarray]: + subsample_method: str = 'pdist_ring', inside_radius = None, outside_radius = None) -> tuple[np.ndarray, np.ndarray]: """ - Wrapper for subsample methods of sample_multirange_variogram + (Not used by default) + Wrapper for subsampling pdist methods """ nx, ny = shape # subsample spatially for disk/ring methods - if subsample_method == 'random_disk': + if subsample_method == 'pdist_disk': # Select random center coordinates center_x = np.random.choice(nx, 1)[0] center_y = np.random.choice(ny, 1)[0] @@ -356,14 +385,14 @@ def _subsample_wrapper(values: np.ndarray, coords: np.ndarray, shape: tuple[int, out_radius=outside_radius) index = index_ring.ravel() - elif subsample_method == 'random_ring': + elif subsample_method == 'pdist_ring': # Select random center coordinates center_x = np.random.choice(nx, 1)[0] center_y = np.random.choice(ny, 1)[0] index_disk = create_circular_mask((nx, ny), center=[center_x, center_y], radius=inside_radius) index = index_disk.ravel() - if subsample_method in ['random_disk', 'random_ring']: + if subsample_method in ['pdist_disk', 'pdist_ring']: values_sp = values[index] coords_sp = coords[index, :] else: @@ -376,101 +405,26 @@ def _subsample_wrapper(values: np.ndarray, coords: np.ndarray, shape: tuple[int, return values_sub, coords_sub - -def sample_multirange_variogram(values: Union[np.ndarray,RasterType], gsd: float = None, coords: np.ndarray = None, - subsample: int = 10000, subsample_method: str = 'random_ring', - multi_ranges: list[float] = None, nrun: int = 1, nproc: int = 1, **kwargs) -> pd.DataFrame: +def _aggregate_pdist_empirical_variogram(values: np.ndarray, coords: np.ndarray, gsd: float, shape: tuple, maxlag: float, + subsample_method: str, multi_ranges: list, nproc:int, nrun:int, subsample: int, **kwargs) -> pd.DataFrame: """ - Sample empirical variograms with binning adaptable to multiple ranges and subsampling adapted for raster data. - By default, subsamples into rings of varying radius between the pixel size and the extent of the provided raster. - Variograms are derived independently for several runs and ranges, and later aggregated, to more effectively sample - spatial lags at different order of magnitudes with the millions of samples of raster data. - - If values are provided as a Raster subclass, nothing else is required. - If values are provided as a 2D array (M,N), a ground sampling distance is sufficient to derive the distances. - If values are provided as a 1D array (N), an array of coordinates (N,2) or (2,N) is expected. - - Spatial subsampling method argument subsample_method can be one of "random_point", "random_disk" and "random_ring". - A list of ranges to subsample independently can be passed through multi_ranges as a list of successive maximum ranges. - If the subsampling method selected is "random_point", the multi-range argument is ignored as range has no effect on - this subsampling method. - - :param values: values - :param gsd: ground sampling distance - :param coords: coordinates - :param subsample_method: spatial subsampling method - :param multi_ranges: list of ranges with successive subsampling and binning - :param subsample: number of samples to randomly draw from the values - :param nrun: number of runs - :param nproc: number of processing cores - - :return: empirical variogram (variance, lags, counts) + (Not used by default) + Aggregating subfunction of sample_multirange_variogram for pdist methods. + The pairwise differences are calculated within each subsample. """ - # First, check all that the values provided are OK - if isinstance(values, Raster): - coords = values.coords() - values, mask = get_array_and_mask(values.data) - elif isinstance(values, (np.ndarray, np.ma.masked_array)): - values, mask = get_array_and_mask(values) - else: - raise TypeError('Values must be of type np.ndarray, np.ma.masked_array or Raster subclass.') - values = values.squeeze() - - # Then, check if the logic between values, coords and gsd is respected - if (gsd is not None or subsample_method in ['random_disk','random_ring']) and values.ndim == 1: - raise TypeError('Values array must be 2D when providing ground sampling distance, or random disk/ring method.') - elif coords is not None and values.ndim != 1: - raise TypeError('Values array must be 1D when providing coordinates.') - elif coords is not None and (coords.shape[0] != 2 and coords.shape[1] != 2): - raise TypeError('The coordinates array must have one dimension with length equal to 2') - - if subsample_method not in ['random_point','random_disk','random_ring']: - raise TypeError('The subsampling method must be one of "random_point", "random_disk" or "random_ring".') - - # Defaulting to coordinates if those are provided - if coords is not None: - nx = None - ny = None - if coords.shape[0] == 2 and coords.shape[1] != 2: - coords = np.transpose(coords) - # Otherwise, we use the ground sampling distance - else: - nx, ny = np.shape(values) - x, y = np.meshgrid(np.arange(0, values.shape[0] * gsd, gsd), np.arange(0, values.shape[1] * gsd, gsd)) - coords = np.dstack((x.flatten(), y.flatten())).squeeze() - values = values.flatten() - - # Default value we want to use if no binning function, number of lags, and maximum lags are defined - if 'bin_func' not in kwargs.keys(): - kwargs.update({'bin_func': 'even'}) - if 'n_lags' not in kwargs.keys(): - kwargs.update({'n_lags': 10}) - if 'maxlag' not in kwargs.keys(): - # define maximum lag as the maximum distance between coordinates (needed to provide custom bins, otherwise - # skgstat rewrites the maxlag with the subsample of coordinates provided) - if coords is not None: - maxlag = np.sqrt((np.max(coords[:, 0]) - np.min(coords[:, 0])) ** 2 + - (np.max(coords[:, 1]) - np.min(coords[:, 1])) ** 2) / 2 - else: - maxlag = np.sqrt((nx*gsd)**2 + (ny*gsd)**2) - # also need a cutoff value to get the exact same bins - kwargs.update({'maxlag': maxlag}) - else: - maxlag = kwargs.get('maxlag') - # If no multi_ranges are provided, define a logical default behaviour with the pixel size and grid size - if subsample_method in ['random_disk','random_ring']: + if subsample_method in ['random_disk', 'random_ring']: if multi_ranges is None: # Get the ground sampling distance if gsd is None: - gsd = np.sqrt((coords[0,0] - coords[0,1])**2 + (coords[0,0]-coords[1,0])**2) + gsd = np.sqrt((coords[0, 0] - coords[0, 1]) ** 2 + (coords[0, 0] - coords[1, 0]) ** 2) # Define list of ranges as exponent 2 of the resolution until the maximum range multi_ranges = [] # We start at 10 times the ground sampling distance - new_range = gsd*10 - while new_range < maxlag/2: + new_range = gsd * 10 + while new_range < maxlag / 2: multi_ranges.append(new_range) new_range *= 2 multi_ranges.append(maxlag) @@ -478,9 +432,9 @@ def sample_multirange_variogram(values: Union[np.ndarray,RasterType], gsd: float # Define subsampling parameters list_inside_radius, list_outside_radius = ([] for i in range(2)) binned_ranges = [0] + multi_ranges - for i in range(len(binned_ranges)-1): + for i in range(len(binned_ranges) - 1): - outside_radius = binned_ranges[i+1]+5*gsd + outside_radius = binned_ranges[i + 1] + 5 * gsd if subsample_method == 'random_ring': inside_radius = binned_ranges[i] else: @@ -503,17 +457,17 @@ def sample_multirange_variogram(values: Union[np.ndarray,RasterType], gsd: float print('Using 1 core...') list_df_nb = [] for i in range(nrun): - values_sub, coords_sub = _subsample_wrapper(values, coords, shape=(nx,ny), subsample=subsample, subsample_method=subsample_method, - inside_radius=list_inside_radius[multi_ranges.index(r)], outside_radius=list_outside_radius[multi_ranges.index(r)]) - print(values_sub) - print(coords_sub) + values_sub, coords_sub = _subsample_wrapper(values, coords, shape=shape, subsample=subsample, + subsample_method=subsample_method, + inside_radius=list_inside_radius[multi_ranges.index(r)], + outside_radius=list_outside_radius[multi_ranges.index(r)]) if len(values_sub) == 0: continue - df = get_empirical_variogram(values=values_sub, coords=coords_sub, **kwargs) + df = _get_pdist_empirical_variogram(values=values_sub, coords=coords_sub, **kwargs) df['run'] = i list_df_nb.append(df) else: - print('Using '+str(nproc) + ' cores...') + print('Using ' + str(nproc) + ' cores...') list_values_sub = [] list_coords_sub = [] for i in range(nrun): @@ -524,8 +478,9 @@ def sample_multirange_variogram(values: Union[np.ndarray,RasterType], gsd: float list_coords_sub.append(coords_sub) pool = mp.Pool(nproc, maxtasksperchild=1) - argsin = [{'values': list_values_sub[i], 'coords': list_coords_sub[i], 'i':i, 'max_i': nrun} for i in range(nrun)] - list_df = pool.map(partial(wrapper_get_empirical_variogram, **kwargs), argsin, chunksize=1) + argsin = [{'values': list_values_sub[i], 'coords': list_coords_sub[i], 'i': i, 'max_i': nrun} for i in + range(nrun)] + list_df = pool.map(partial(_wrapper_get_pdist_empirical_variogram, **kwargs), argsin, chunksize=1) pool.close() pool.join() @@ -558,6 +513,110 @@ def sample_multirange_variogram(values: Union[np.ndarray,RasterType], gsd: float return df +def sample_multirange_variogram(values: Union[np.ndarray,RasterType], gsd: float = None, coords: np.ndarray = None, + subsample: int = 10000, subsample_method: str = 'cdist_equidistant', + pdist_multi_ranges: list[float] = None, nrun: int = 1, nproc: int = 1, **kwargs) -> pd.DataFrame: + """ + Sample empirical variograms with binning adaptable to multiple ranges and spatial subsampling adapted for raster data. + By default, subsampling is based on RasterEquidistantMetricSpace implemented in scikit-gstat. This method samples more + effectively large grid data by isolating pairs of spatially equidistant ensembles for distributed pairwise comparison. + In practice, two subsamples are drawn for pairwise comparison: one from a disk of certain radius within the grid, and + another one from rings of larger radii that increase steadily between the pixel size and the extent of the raster. + Those disk and rings are sampled several times across the grid using random centers. + + If values are provided as a Raster subclass, nothing else is required. + If values are provided as a 2D array (M,N), a ground sampling distance is sufficient to derive the distances. + If values are provided as a 1D array (N), an array of coordinates (N,2) or (2,N) is expected. + + Spatial subsampling method argument subsample_method can be one of "cdist_equidistant", "cdist_point", "pdist_point", + "pdist_disk" and "pdist_ring". + The cdist methods use MetricSpace classes of scikit-gstat and do pairwise comparison of two ensembles as in + scipy.spatial.cdist. + The pdist methods use methods to subsample the Raster directly and do pairwise comparison within a single + ensemble as in scipy.spatial.pdist. + + For the cdist methods, the variogram is estimated in a single run from the MetricSpace. + + For the pdist methods, an iterative process is required: a list of ranges subsampled independently is used. + Variograms are derived independently for several runs and ranges using each pairwise sample, and later aggregated. + If the subsampling method selected is "random_point", the multi-range argument is ignored as range has no effect on + this subsampling method. + + :param values: values + :param gsd: ground sampling distance + :param coords: coordinates + :param subsample_method: spatial subsampling method + :param pdist_multi_ranges: list of ranges to use for pdist methods + :param subsample: number of samples to randomly draw from the values + :param nrun: number of runs + :param nproc: number of processing cores + + :return: empirical variogram (variance, lags, counts) + """ + # First, check all that the values provided are OK + if isinstance(values, Raster): + coords = values.coords() + values, mask = get_array_and_mask(values.data) + elif isinstance(values, (np.ndarray, np.ma.masked_array)): + values, mask = get_array_and_mask(values) + else: + raise TypeError('Values must be of type np.ndarray, np.ma.masked_array or Raster subclass.') + values = values.squeeze() + + # Then, check if the logic between values, coords and gsd is respected + if (gsd is not None or subsample_method in ['pdist_disk','pdist_ring']) and values.ndim == 1: + raise TypeError('Values array must be 2D when providing ground sampling distance, or random disk/ring method.') + elif coords is not None and values.ndim != 1: + raise TypeError('Values array must be 1D when providing coordinates.') + elif coords is not None and (coords.shape[0] != 2 and coords.shape[1] != 2): + raise TypeError('The coordinates array must have one dimension with length equal to 2') + + if subsample_method not in ['cdist_equidistant','cdist_point','pdist_point','pdist_disk','pdist_ring']: + raise TypeError('The subsampling method must be one of "cdist_equidistant, "cdist_point", "pdist_point", ' + '"pdist_disk" or "pdist_ring".') + + # Defaulting to coordinates if those are provided + if coords is not None: + nx = None + ny = None + if coords.shape[0] == 2 and coords.shape[1] != 2: + coords = np.transpose(coords) + # Otherwise, we use the ground sampling distance + else: + nx, ny = np.shape(values) + x, y = np.meshgrid(np.arange(0, values.shape[0] * gsd, gsd), np.arange(0, values.shape[1] * gsd, gsd)) + coords = np.dstack((x.flatten(), y.flatten())).squeeze() + values = values.flatten() + + # Default value we want to use if no binning function, number of lags, and maximum lags are defined + if 'bin_func' not in kwargs.keys(): + kwargs.update({'bin_func': 'even'}) + if 'n_lags' not in kwargs.keys(): + kwargs.update({'n_lags': 10}) + if 'maxlag' not in kwargs.keys(): + # define maximum lag as the maximum distance between coordinates (needed to provide custom bins, otherwise + # skgstat rewrites the maxlag with the subsample of coordinates provided) + if coords is not None: + maxlag = np.sqrt((np.max(coords[:, 0]) - np.min(coords[:, 0])) ** 2 + + (np.max(coords[:, 1]) - np.min(coords[:, 1])) ** 2) / 2 + else: + maxlag = np.sqrt((nx*gsd)**2 + (ny*gsd)**2) + # also need a cutoff value to get the exact same bins + kwargs.update({'maxlag': maxlag}) + else: + maxlag = kwargs.get('maxlag') + + # Derive the variogram + if subsample_method in ['cdist_equidistant', 'cdist_point']: + # Simple wrapper for the skgstat Variogram function for cdist methods + df = _get_cdist_empirical_variogram(values=values, coords=coords, cdist_method=subsample_method, **kwargs) + else: + # Aggregating several skgstat Variogram after iterative subsampling of specific points in the Raster + df = _aggregate_pdist_empirical_variogram(values=values, coords=coords, gsd=gsd, shape=(nx,ny), maxlag=maxlag, + subsample_method=subsample_method, multi_ranges=pdist_multi_ranges, nproc=nproc, + nrun=nrun, subsample=subsample, **kwargs) + + return df def fit_model_sum_vgm(list_model: list[str], emp_vgm_df: pd.DataFrame) -> tuple[Callable, list[float]]: """ From fa471c956a59a3551be79fbb703d53dfc8f1680e Mon Sep 17 00:00:00 2001 From: rhugonne Date: Wed, 21 Jul 2021 14:36:31 +0200 Subject: [PATCH 063/113] deal with nodata and simplify quantile binning --- examples/plot_nonstationary_error.py | 54 ++++++++++++++-------------- 1 file changed, 26 insertions(+), 28 deletions(-) diff --git a/examples/plot_nonstationary_error.py b/examples/plot_nonstationary_error.py index 46544b47..3b735642 100644 --- a/examples/plot_nonstationary_error.py +++ b/examples/plot_nonstationary_error.py @@ -36,10 +36,6 @@ glacier_outlines = gu.Vector(xdem.examples.get_path("longyearbyen_glacier_outlines")) mask_glacier = glacier_outlines.create_mask(dh) -# %% -# We remove values on unstable terrain -dh.data[mask_glacier] = np.nan - # %% # We use the reference DEM to derive terrain variables such as slope, aspect, curvature (see :ref:`sphx_glr_auto_examples_plot_terrain_attributes.py`) # that we'll use to explore potential non-stationarities in elevation measurement error @@ -50,11 +46,19 @@ attribute=['slope','aspect', 'planform_curvature', 'profile_curvature'], resolution=ref_dem.res) +# %% +# We remove values on unstable terrain +dh_arr = dh.data[~mask_glacier] +slope_arr = slope[~mask_glacier] +aspect_arr = aspect[~mask_glacier] +planc_arr = planc[~mask_glacier] +profc_arr = profc[~mask_glacier] + # %% # We use :func:`xdem.spatialstats.nd_binning` to perform N-dimensional binning on all those terrain variables, with uniform # bin length divided by 30. We use the :ref:`spatial_stats_nmad` as a robust measure of `statistical dispersion `_. -df = xdem.spatialstats.nd_binning(values=dh.data, list_var=[slope, aspect, planc, profc], +df = xdem.spatialstats.nd_binning(values=dh_arr, list_var=[slope_arr, aspect_arr, planc_arr, profc_arr], list_var_names=['slope','aspect','planc','profc'], statistics=['count', xdem.spatialstats.nmad], list_var_bins=30) @@ -103,9 +107,9 @@ # time and instead bin one at a time. # We define 1000 quantile bins of size 0.001 (equivalent to 0.1% percentile bins) for the profile curvature: -df = xdem.spatialstats.nd_binning(values=dh.data, list_var=[profc], list_var_names=['profc'], +df = xdem.spatialstats.nd_binning(values=dh_arr, list_var=[profc_arr], list_var_names=['profc'], statistics=['count', np.nanmedian, xdem.spatialstats.nmad], - list_var_bins=[[np.nanquantile(profc,0.001*i) for i in range(1001)]]) + list_var_bins=[np.nanquantile(profc_arr,np.linspace(0,1,1000))]) xdem.spatialstats.plot_1d_binning(df, 'profc', 'nmad', 'Profile curvature (100 m$^{-1}$)', 'NMAD of dh (m)') # %% @@ -113,9 +117,9 @@ # for higher positive or negative curvature. # What about the role of the plan curvature? -df = xdem.spatialstats.nd_binning(values=dh.data, list_var=[planc], list_var_names=['planc'], +df = xdem.spatialstats.nd_binning(values=dh_arr, list_var=[planc_arr], list_var_names=['planc'], statistics=['count', np.nanmedian, xdem.spatialstats.nmad], - list_var_bins=[[np.nanquantile(planc,0.001*i) for i in range(1001)]]) + list_var_bins=[np.nanquantile(planc_arr,np.linspace(0,1,1000))]) xdem.spatialstats.plot_1d_binning(df, 'planc', 'nmad', 'Planform curvature (100 m$^{-1}$)', 'NMAD of dh (m)') # %% @@ -123,10 +127,10 @@ # To simplify the analysis, we here combine those curvatures into the maximum absolute curvature: # Derive maximum absolute curvature -maxc = np.maximum(np.abs(planc),np.abs(profc)) -df = xdem.spatialstats.nd_binning(values=dh.data, list_var=[maxc], list_var_names=['maxc'], +maxc_arr = np.maximum(np.abs(planc_arr),np.abs(profc_arr)) +df = xdem.spatialstats.nd_binning(values=dh_arr, list_var=[maxc_arr], list_var_names=['maxc'], statistics=['count', np.nanmedian, xdem.spatialstats.nmad], - list_var_bins=[[np.nanquantile(maxc,0.002*i) for i in range(501)]]) + list_var_bins=[np.nanquantile(maxc_arr,np.linspace(0,1,1000))]) xdem.spatialstats.plot_1d_binning(df, 'maxc', 'nmad', 'Maximum absolute curvature (100 m$^{-1}$)', 'NMAD of dh (m)') # %% @@ -138,7 +142,7 @@ # # We need to explore the variability with both slope and curvature at the same time: -df = xdem.spatialstats.nd_binning(values=dh.data, list_var=[slope, maxc], list_var_names=['slope', 'maxc'], +df = xdem.spatialstats.nd_binning(values=dh_arr, list_var=[slope_arr, maxc_arr], list_var_names=['slope', 'maxc'], statistics=['count', np.nanmedian, xdem.spatialstats.nmad], list_var_bins=30) @@ -150,17 +154,15 @@ # # If we use custom quantiles for both binning variables, and adjust the plot scale: -custom_bin_slope = np.unique([np.quantile(slope,0.05*i) for i in range(20)] - + [np.quantile(slope,0.95+0.02*i) for i in range(2)] - + [np.quantile(slope,0.98 + 0.005*i) for i in range(3)] - + [np.quantile(slope,0.995 + 0.001*i) for i in range(6)]) +custom_bin_slope = np.unique(np.concatenate([np.quantile(slope_arr,np.linspace(0,0.95,20)), + np.quantile(slope_arr,np.linspace(0.96,0.99,5)), + np.quantile(slope_arr,np.linspace(0.991,1,10))])) -custom_bin_curvature = np.unique([np.quantile(maxc,0.05*i) for i in range(20)] - + [np.quantile(maxc,0.95+0.02*i) for i in range(2)] - + [np.quantile(maxc,0.98 + 0.005*i) for i in range(3)] - + [np.quantile(maxc,0.995 + 0.001*i) for i in range(6)]) +custom_bin_curvature = np.unique(np.concatenate([np.quantile(maxc_arr,np.linspace(0,0.95,20)), + np.quantile(maxc_arr,np.linspace(0.96,0.99,5)), + np.quantile(maxc_arr,np.linspace(0.991,1,10))])) -df = xdem.spatialstats.nd_binning(values=dh.data, list_var=[slope, maxc], list_var_names=['slope', 'maxc'], +df = xdem.spatialstats.nd_binning(values=dh_arr, list_var=[slope_arr, maxc_arr], list_var_names=['slope', 'maxc'], statistics=['count', np.nanmedian, xdem.spatialstats.nmad], list_var_bins=[custom_bin_slope,custom_bin_curvature]) xdem.spatialstats.plot_2d_binning(df, 'slope', 'maxc', 'nmad', 'Slope (degrees)', 'Maximum absolute curvature (100 m$^{-1}$)', 'NMAD of dh (m)', scale_var_2='log', vmin=2, vmax=10) @@ -193,6 +195,7 @@ # %% # The same function can be used to estimate the spatial distribution of the elevation measurement error over the area: +maxc = np.maximum(profc, planc) dh_err = slope_curv_to_dh_err((slope, maxc)) plt.figure(figsize=(8, 5)) @@ -205,9 +208,4 @@ plt.imshow(dh_err.squeeze(), cmap="Reds", vmin=2, vmax=8, extent=plt_extent) cbar = plt.colorbar() cbar.set_label('Elevation measurement error (m)') -plt.show() - - - - - +plt.show() \ No newline at end of file From ee1adb9c7c910133a92b9b47fc7a199bd10c007e Mon Sep 17 00:00:00 2001 From: rhugonne Date: Wed, 21 Jul 2021 17:12:03 +0200 Subject: [PATCH 064/113] reorganize variogram sampling with skgstat update, pass kwargs down, pass random states for robust testing --- tests/test_spatialstats.py | 13 +- xdem/spatial_tools.py | 15 +- xdem/spatialstats.py | 413 +++++++++++++++++++++---------------- 3 files changed, 257 insertions(+), 184 deletions(-) diff --git a/tests/test_spatialstats.py b/tests/test_spatialstats.py index f8879926..375b517f 100644 --- a/tests/test_spatialstats.py +++ b/tests/test_spatialstats.py @@ -31,21 +31,20 @@ def load_ref_and_diff() -> tuple[gu.georaster.Raster, gu.georaster.Raster, np.nd class TestVariogram: - # check that the scripts are running @pytest.mark.skip("This test fails randomly! It needs to be fixed.") def test_empirical_fit_variogram_running(self): - # get some data + # Load data diff, mask = load_ref_and_diff()[1:3] x, y = diff.coords(offset='center') coords = np.dstack((x.flatten(), y.flatten())).squeeze() - # check the base script runs with right input shape - df = xdem.spatialstats.get_empirical_variogram( - values=diff.data.flatten(), - coords=coords, - nsamp=1000) + # Check the base script runs with right input shape + df = xdem.spatialstats.sample_multirange_variogram( + values=diff.data, + gsd=diff.res[0], + subsample=100) # check the wrapper script runs with various inputs # with gsd as input diff --git a/xdem/spatial_tools.py b/xdem/spatial_tools.py index 3601ac34..1b7b90b9 100644 --- a/xdem/spatial_tools.py +++ b/xdem/spatial_tools.py @@ -436,17 +436,26 @@ def subdivide_array(shape: tuple[int, ...], count: int) -> np.ndarray: def subsample_raster( - array: Union[np.ndarray, np.ma.masked_array], subsample: Union[float, int], return_indices: bool = False -) -> np.ndarray: + array: Union[np.ndarray, np.ma.masked_array], subsample: Union[float, int], return_indices: bool = False, + random_state : None | np.random.RandomState | int = None) -> np.ndarray: """ Randomly subsample a 1D or 2D array by a subsampling factor, taking only non NaN/masked values. :param subsample: If <= 1, will be considered a fraction of valid pixels to extract. If > 1 will be considered the number of pixels to extract. :param return_indices: If set to True, will return the extracted indices only. + :param random_state: Random state, or seed number to use for random calculations (for testing) :returns: The subsampled array (1D) or the indices to extract (same shape as input array) """ + # Define state for random subsampling (to fix results during testing) + if random_state is None: + rnd = np.random.default_rng() + elif isinstance(random_state, np.random.RandomState): + rnd = random_state + else: + rnd = np.random.RandomState(np.random.MT19937(np.random.SeedSequence(random_state))) + # Get number of points to extract if (subsample <= 1) & (subsample > 0): npoints = int(subsample * np.size(array)) @@ -465,7 +474,7 @@ def subsample_raster( npoints = np.size(valids) # Randomly extract npoints without replacement - indices = np.random.choice(valids, npoints, replace=False) + indices = rnd.choice(valids, npoints, replace=False) unraveled_indices = np.unravel_index(indices, array.shape) if return_indices: diff --git a/xdem/spatialstats.py b/xdem/spatialstats.py index 1543631a..c4e813f6 100644 --- a/xdem/spatialstats.py +++ b/xdem/spatialstats.py @@ -251,70 +251,8 @@ def nd_binning(values: np.ndarray, list_var: Iterable[np.ndarray], list_var_name return df_concat -def _get_pdist_empirical_variogram(values: np.ndarray, coords: np.ndarray, **kwargs) -> pd.DataFrame: - """ - Get empirical variogram from skgstat.Variogram object calculating pairwise distances within the sample - - :param values: values - :param coords: coordinates - :return: empirical variogram (variance, lags, counts) - - """ - - V = skg.Variogram(coordinates=coords, values=values, normalize=False, fit_method = None, **kwargs) - - # To derive the middle of the bins - bins, exp = V.get_empirical() - count = V.bin_count - - df = pd.DataFrame() - df = df.assign(exp=exp, bins=bins, count=count) - - return df - - -def _get_cdist_empirical_variogram(values: np.ndarray, coords: np.ndarray, cdist_method: str, **kwargs) -> pd.DataFrame: - """ - Get empirical variogram from skgstat.Variogram object calculating pairwise distances between two sample collections - of a MetricSpace (see scikit-gstat documentation for more details) - - :param values: values - :param coords: coordinates - :return: empirical variogram (variance, lags, counts) - - """ - if cdist_method == 'cdist_point': - ms = skg.ProbabalisticMetricSpace(coords=coords, samples=samples) - elif cdist_method == 'cdist_equidistant': - ms = skg.RasterEquidistantMetricSpace(coords=coords, shape=shape, extent=extent) - - V = skg.Variogram(ms, values=values, normalize=False, fit_method=None, **kwargs) - - # To derive the middle of the bins - bins, exp = V.get_empirical() - count = V.bin_count - - df = pd.DataFrame() - df = df.assign(exp=exp, bins=bins, count=count) - - return df - - -def _wrapper_get_pdist_empirical_variogram(argdict: dict, **kwargs) -> pd.DataFrame: - """ - Multiprocessing wrapper for get_pdist_empirical_variogram - - :param argdict: Keyword argument to pass to get_empirical_variogram() - - :return: empirical variogram (variance, lags, counts) - - """ - print('Working on subsample '+str(argdict['i']) + ' out of '+str(argdict['max_i'])) - - return _get_pdist_empirical_variogram(values=argdict['values'], coords=argdict['coords'], **kwargs) - - -def create_circular_mask(shape: Union[int, Sequence[int]], center: Optional[list[float]] = None, radius: Optional[float] = None) -> np.ndarray: +def create_circular_mask(shape: Union[int, Sequence[int]], center: Optional[list[float]] = None, + radius: Optional[float] = None) -> np.ndarray: """ Create circular mask on a raster, defaults to the center of the array and it's half width @@ -343,7 +281,8 @@ def create_circular_mask(shape: Union[int, Sequence[int]], center: Optional[list return mask -def create_ring_mask(shape: Union[int, Sequence[int]], center: Optional[list[float]] = None, in_radius: float = 0., out_radius: Optional[float] = None) -> np.ndarray: +def create_ring_mask(shape: Union[int, Sequence[int]], center: Optional[list[float]] = None, in_radius: float = 0., + out_radius: Optional[float] = None) -> np.ndarray: """ Create ring mask on a raster, defaults to the center of the array and a circle mask of half width of the array @@ -369,69 +308,86 @@ def create_ring_mask(shape: Union[int, Sequence[int]], center: Optional[list[flo def _subsample_wrapper(values: np.ndarray, coords: np.ndarray, shape: tuple[int,int] = None, subsample: int = 10000, - subsample_method: str = 'pdist_ring', inside_radius = None, outside_radius = None) -> tuple[np.ndarray, np.ndarray]: + subsample_method: str = 'pdist_ring', random_state: None | np.random.RandomState | int = None, + inside_radius = None, outside_radius = None) -> tuple[np.ndarray, np.ndarray]: """ (Not used by default) Wrapper for subsampling pdist methods """ nx, ny = shape - # subsample spatially for disk/ring methods - if subsample_method == 'pdist_disk': - # Select random center coordinates - center_x = np.random.choice(nx, 1)[0] - center_y = np.random.choice(ny, 1)[0] - index_ring = create_ring_mask((nx, ny), center=[center_x, center_y], in_radius=inside_radius, - out_radius=outside_radius) - index = index_ring.ravel() + # Define state for random subsampling (to fix results during testing) + if random_state is None: + rnd = np.random.default_rng() + elif isinstance(random_state, np.random.RandomState): + rnd = random_state + else: + rnd = np.random.RandomState(np.random.MT19937(np.random.SeedSequence(random_state))) - elif subsample_method == 'pdist_ring': + # Subsample spatially for disk/ring methods + if subsample_method in ['pdist_disk', 'pdist_ring']: # Select random center coordinates - center_x = np.random.choice(nx, 1)[0] - center_y = np.random.choice(ny, 1)[0] - index_disk = create_circular_mask((nx, ny), center=[center_x, center_y], radius=inside_radius) - index = index_disk.ravel() + center_x = rnd.choice(nx, 1)[0] + center_y = rnd.choice(ny, 1)[0] + if subsample_method == 'pdist_disk': + index_ring = create_ring_mask((nx, ny), center=[center_x, center_y], in_radius=inside_radius, + out_radius=outside_radius) + index = index_ring.ravel() + else: + index_disk = create_circular_mask((nx, ny), center=[center_x, center_y], radius=inside_radius) + index = index_disk.ravel() - if subsample_method in ['pdist_disk', 'pdist_ring']: values_sp = values[index] coords_sp = coords[index, :] + else: values_sp = values coords_sp = coords - index = subsample_raster(values_sp, subsample=subsample, return_indices=True) + index = subsample_raster(values_sp, subsample=subsample, return_indices=True, random_state=rnd) values_sub = values_sp[index] coords_sub = coords_sp[index, :] return values_sub, coords_sub -def _aggregate_pdist_empirical_variogram(values: np.ndarray, coords: np.ndarray, gsd: float, shape: tuple, maxlag: float, - subsample_method: str, multi_ranges: list, nproc:int, nrun:int, subsample: int, **kwargs) -> pd.DataFrame: +def _aggregate_pdist_empirical_variogram(values: np.ndarray, coords: np.ndarray, subsample: int, shape: tuple, + subsample_method: str, pdist_multi_ranges: Optional[list[float]] = None, + **kwargs) -> pd.DataFrame: """ (Not used by default) Aggregating subfunction of sample_multirange_variogram for pdist methods. The pairwise differences are calculated within each subsample. """ + # Get the ground sampling distance + gsd = np.mean([coords[0, 0] - coords[0, 1], coords[0, 0] - coords[1, 0]]) + + # Define maxlag automatically if not done + if 'maxlag' not in kwargs.keys(): + # define maximum lag as the maximum distance between coordinates (needed to provide custom bins, otherwise + # skgstat rewrites the maxlag with the subsample of coordinates provided) + maxlag = np.sqrt((shape[0] * gsd) ** 2 + (shape[1] * gsd) ** 2) + # also need a cutoff value to get the exact same bins + kwargs.update({'maxlag': maxlag}) + else: + maxlag = kwargs.get('maxlag') + # If no multi_ranges are provided, define a logical default behaviour with the pixel size and grid size if subsample_method in ['random_disk', 'random_ring']: - if multi_ranges is None: - # Get the ground sampling distance - if gsd is None: - gsd = np.sqrt((coords[0, 0] - coords[0, 1]) ** 2 + (coords[0, 0] - coords[1, 0]) ** 2) + if pdist_multi_ranges is None: # Define list of ranges as exponent 2 of the resolution until the maximum range - multi_ranges = [] + pdist_multi_ranges = [] # We start at 10 times the ground sampling distance new_range = gsd * 10 while new_range < maxlag / 2: - multi_ranges.append(new_range) + pdist_multi_ranges.append(new_range) new_range *= 2 - multi_ranges.append(maxlag) + pdist_multi_ranges.append(maxlag) # Define subsampling parameters list_inside_radius, list_outside_radius = ([] for i in range(2)) - binned_ranges = [0] + multi_ranges + binned_ranges = [0] + pdist_multi_ranges for i in range(len(binned_ranges) - 1): outside_radius = binned_ranges[i + 1] + 5 * gsd @@ -444,78 +400,145 @@ def _aggregate_pdist_empirical_variogram(values: np.ndarray, coords: np.ndarray, list_inside_radius.append(inside_radius) else: # For random point selection, no need for multi-range parameters - multi_ranges = [maxlag] + pdist_multi_ranges = [maxlag] list_outside_radius = [None] list_inside_radius = [None] # Estimate variogram with specific subsampling at multiple ranges - list_df_r = [] - for r in multi_ranges: + list_df_range = [] + for j in range(len(pdist_multi_ranges)): - # Differentiate between 1 core and several cores for multiple runs - if nproc == 1: - print('Using 1 core...') - list_df_nb = [] - for i in range(nrun): - values_sub, coords_sub = _subsample_wrapper(values, coords, shape=shape, subsample=subsample, - subsample_method=subsample_method, - inside_radius=list_inside_radius[multi_ranges.index(r)], - outside_radius=list_outside_radius[multi_ranges.index(r)]) - if len(values_sub) == 0: - continue - df = _get_pdist_empirical_variogram(values=values_sub, coords=coords_sub, **kwargs) - df['run'] = i - list_df_nb.append(df) - else: - print('Using ' + str(nproc) + ' cores...') - list_values_sub = [] - list_coords_sub = [] - for i in range(nrun): - index = subsample_raster(values, subsample=subsample, return_indices=True) - values_sub = values[index] - coords_sub = coords[index, :] - list_values_sub.append(values_sub) - list_coords_sub.append(coords_sub) - - pool = mp.Pool(nproc, maxtasksperchild=1) - argsin = [{'values': list_values_sub[i], 'coords': list_coords_sub[i], 'i': i, 'max_i': nrun} for i in - range(nrun)] - list_df = pool.map(partial(_wrapper_get_pdist_empirical_variogram, **kwargs), argsin, chunksize=1) - pool.close() - pool.join() - - list_df_nb = [] - for i in range(10): - df_nb = list_df[i] - df_nb['run'] = i - list_df_nb.append(df_nb) + values_sub, coords_sub = _subsample_wrapper(values, coords, shape = shape, subsample = subsample, + subsample_method = subsample_method, + inside_radius = list_inside_radius[j], + outside_radius = list_outside_radius[j]) + if len(values_sub) == 0: + continue + df_range = _get_pdist_empirical_variogram(values=values_sub, coords=coords_sub, **kwargs) # Aggregate runs - df_r = pd.concat(list_df_nb) - list_df_r.append(df_r) + list_df_range.append(df_range) - # Aggregate multiple ranges subsampling - df = pd.concat(list_df_r) + df = pd.concat(list_df_range) - # For a single run, no multi-run sigma estimated - if nrun == 1: - df['exp_sigma'] = np.nan - # For several runs, group results, use mean as empirical variogram, estimate sigma, and sum the counts + return df + + +def _get_pdist_empirical_variogram(values: np.ndarray, coords: np.ndarray, **kwargs) -> pd.DataFrame: + """ + Get empirical variogram from skgstat.Variogram object calculating pairwise distances within the sample + + :param values: values + :param coords: coordinates + :return: empirical variogram (variance, lags, counts) + + """ + # Get arguments of Variogram class init function + vgm_args = skg.Variogram.__init__.__code__.co_varnames[:skg.Variogram.__init__.__code__.co_argcount] + # Check no other argument is left to be passed + remaining_kwargs = kwargs.copy() + for arg in vgm_args: + remaining_kwargs.pop(arg, None) + if len(remaining_kwargs) != 0: + warnings.warn('Keyword arguments: '+','.join(list(remaining_kwargs.keys()))+ ' were not used.') + # Filter corresponding arguments before passing + filtered_kwargs = {k:kwargs[k] for k in vgm_args if k in kwargs} + + # Derive variogram with default MetricSpace (equivalent to scipy.pdist) + V = skg.Variogram(coordinates=coords, values=values, normalize=False, fit_method=None, **filtered_kwargs) + + # Get the middle value of the bins, empirical variogram values, and bin count + bins, exp = V.get_empirical() + count = V.bin_count + + # Write to dataframe + df = pd.DataFrame() + df = df.assign(exp=exp, bins=bins, count=count) + + return df + + +def _get_cdist_empirical_variogram(values: np.ndarray, coords: np.ndarray, subsample_method: str, + **kwargs) -> pd.DataFrame: + """ + Get empirical variogram from skgstat.Variogram object calculating pairwise distances between two sample collections + of a MetricSpace (see scikit-gstat documentation for more details) + + :param values: values + :param coords: coordinates + :return: empirical variogram (variance, lags, counts) + + """ + # Rename the "subsample" argument into "samples", which is used by skgstat Metric subclasses + kwargs['samples'] = kwargs.pop('subsample') + # Rename the "random_state" argument into "rnd", also used by skgstat Metric subclasses + kwargs['rnd'] = kwargs.pop('random_state') + + # Define MetricSpace function to be used, fetch possible keywords arguments + if subsample_method == 'cdist_point': + # List keyword arguments of the Probabilistic class init function + ms_args = skg.ProbabalisticMetricSpace.__init__.__code__.co_varnames[:skg.ProbabalisticMetricSpace.__init__.__code__.co_argcount] + ms = skg.ProbabalisticMetricSpace else: - df_grouped = df.groupby('bins', dropna=False) - df_mean = df_grouped[['exp']].mean() - df_sig = df_grouped[['exp']].std() - df_count = df_grouped[['count']].sum() - df_mean['bins'] = df_mean.index.values - df_mean['exp_sigma'] = df_sig['exp'] - df_mean['count'] = df_count['count'] - df = df_mean + # List keyword arguments of the RasterEquidistant class init function + ms_args = skg.RasterEquidistantMetricSpace.__init__.__code__.co_varnames[:skg.RasterEquidistantMetricSpace.__init__.__code__.co_argcount] + ms = skg.RasterEquidistantMetricSpace + + # Get arguments of Variogram class init function + vgm_args = skg.Variogram.__init__.__code__.co_varnames[:skg.Variogram.__init__.__code__.co_argcount] + # Check no other argument is left to be passed, accounting for MetricSpace arguments + remaining_kwargs = kwargs.copy() + for arg in vgm_args + ms_args: + remaining_kwargs.pop(arg, None) + if len(remaining_kwargs) != 0: + warnings.warn('Keyword arguments: ' + ', '.join(list(remaining_kwargs.keys())) + ' were not used.') + + # Filter corresponding arguments before passing to MetricSpace function + filtered_ms_kwargs = {k: kwargs[k] for k in ms_args if k in kwargs} + M = ms(coords=coords, **filtered_ms_kwargs) + + # Filter corresponding arguments before passing to Variogram function + filtered_var_kwargs = {k: kwargs[k] for k in vgm_args if k in kwargs} + V = skg.Variogram(M, values=values, normalize=False, fit_method=None, **filtered_var_kwargs) + + # Get the middle value of the bins, empirical variogram values, and bin count + bins, exp = V.get_empirical() + count = V.bin_count + + # Write to dataframe + df = pd.DataFrame() + df = df.assign(exp=exp, bins=bins, count=count) return df +def _wrapper_get_empirical_variogram(argdict: dict) -> pd.DataFrame: + """ + Multiprocessing wrapper for get_pdist_empirical_variogram and get_cdist_empirical variogram + + :param argdict: Keyword argument to pass to get_pdist/cdist_empirical_variogram + + :return: empirical variogram (variance, lags, counts) + + """ + if argdict['verbose']: + print('Working on run '+str(argdict['i']) + ' out of '+str(argdict['imax'])) + argdict.pop('i') + argdict.pop('imax') + + if argdict['subsample_method'] in ['cdist_equidistant', 'cdist_point']: + # Simple wrapper for the skgstat Variogram function for cdist methods + get_variogram = _get_cdist_empirical_variogram + else: + # Aggregating several skgstat Variogram after iterative subsampling of specific points in the Raster + get_variogram = _aggregate_pdist_empirical_variogram + + return get_variogram(**argdict) + + def sample_multirange_variogram(values: Union[np.ndarray,RasterType], gsd: float = None, coords: np.ndarray = None, - subsample: int = 10000, subsample_method: str = 'cdist_equidistant', - pdist_multi_ranges: list[float] = None, nrun: int = 1, nproc: int = 1, **kwargs) -> pd.DataFrame: + subsample: int = 10000, subsample_method: str = 'cdist_equidistant', nrun: int = 1, + nproc: int = 1, verbose=False, random_state: None | np.random.RandomState | int = None, + **kwargs) -> pd.DataFrame: """ Sample empirical variograms with binning adaptable to multiple ranges and spatial subsampling adapted for raster data. By default, subsampling is based on RasterEquidistantMetricSpace implemented in scikit-gstat. This method samples more @@ -532,7 +555,7 @@ def sample_multirange_variogram(values: Union[np.ndarray,RasterType], gsd: float "pdist_disk" and "pdist_ring". The cdist methods use MetricSpace classes of scikit-gstat and do pairwise comparison of two ensembles as in scipy.spatial.cdist. - The pdist methods use methods to subsample the Raster directly and do pairwise comparison within a single + The pdist methods use methods to subsample the Raster points directly and do pairwise comparison within a single ensemble as in scipy.spatial.pdist. For the cdist methods, the variogram is estimated in a single run from the MetricSpace. @@ -542,14 +565,18 @@ def sample_multirange_variogram(values: Union[np.ndarray,RasterType], gsd: float If the subsampling method selected is "random_point", the multi-range argument is ignored as range has no effect on this subsampling method. + For pdist methods, additional keyword arguments are passed to skgstat.Variogram. + For cdist methods, keyword arguments are divided between skgstat.Variogram and skgstat.MetricSpace. + :param values: values :param gsd: ground sampling distance :param coords: coordinates - :param subsample_method: spatial subsampling method - :param pdist_multi_ranges: list of ranges to use for pdist methods :param subsample: number of samples to randomly draw from the values + :param subsample_method: spatial subsampling method :param nrun: number of runs :param nproc: number of processing cores + :param verbose: print statements during processing + :param random_state: random state or seed number to use for calculations (to fix drawings during testing) :return: empirical variogram (variance, lags, counts) """ @@ -564,8 +591,9 @@ def sample_multirange_variogram(values: Union[np.ndarray,RasterType], gsd: float values = values.squeeze() # Then, check if the logic between values, coords and gsd is respected - if (gsd is not None or subsample_method in ['pdist_disk','pdist_ring']) and values.ndim == 1: - raise TypeError('Values array must be 2D when providing ground sampling distance, or random disk/ring method.') + if (gsd is not None or subsample_method in ['cdist_equidistant', 'pdist_disk','pdist_ring']) and values.ndim == 1: + raise TypeError('Values array must be 2D when using any of the "cdist_equidistant", "pdist_disk" and ' + '"pdist_ring" methods, or providing a ground sampling distance instead of coordinates.') elif coords is not None and values.ndim != 1: raise TypeError('Values array must be 1D when providing coordinates.') elif coords is not None and (coords.shape[0] != 2 and coords.shape[1] != 2): @@ -579,42 +607,79 @@ def sample_multirange_variogram(values: Union[np.ndarray,RasterType], gsd: float if coords is not None: nx = None ny = None + # Making the shape of coordinates consistent if coords.shape[0] == 2 and coords.shape[1] != 2: coords = np.transpose(coords) - # Otherwise, we use the ground sampling distance + # Otherwise, we use the shape of the array and ground sampling distance to infer relative coordinates (starting at zero) else: nx, ny = np.shape(values) x, y = np.meshgrid(np.arange(0, values.shape[0] * gsd, gsd), np.arange(0, values.shape[1] * gsd, gsd)) coords = np.dstack((x.flatten(), y.flatten())).squeeze() values = values.flatten() + # Keep only valid data + ind_valid = np.isfinite(values) + values = values[ind_valid] + coords = coords[ind_valid, :] + + # TODO: right now this is a bit useless, as it is the same as in skgstat: adapt for grid data # Default value we want to use if no binning function, number of lags, and maximum lags are defined if 'bin_func' not in kwargs.keys(): kwargs.update({'bin_func': 'even'}) if 'n_lags' not in kwargs.keys(): kwargs.update({'n_lags': 10}) - if 'maxlag' not in kwargs.keys(): - # define maximum lag as the maximum distance between coordinates (needed to provide custom bins, otherwise - # skgstat rewrites the maxlag with the subsample of coordinates provided) - if coords is not None: - maxlag = np.sqrt((np.max(coords[:, 0]) - np.min(coords[:, 0])) ** 2 + - (np.max(coords[:, 1]) - np.min(coords[:, 1])) ** 2) / 2 - else: - maxlag = np.sqrt((nx*gsd)**2 + (ny*gsd)**2) - # also need a cutoff value to get the exact same bins - kwargs.update({'maxlag': maxlag}) - else: - maxlag = kwargs.get('maxlag') + + # Prepare necessary arguments to pass to variogram functions + args = {'values': values, 'coords': coords, 'subsample_method': subsample_method, 'subsample': subsample, + 'verbose': verbose, 'random_state': random_state} + if subsample_method in ['cdist_equidistant','pdist_ring','pdist_disk']: + # The shape is needed for those three methods + args.update({'shape': (nx, ny)}) + if subsample_method == 'cdist_equidistant': + # The coordinate extent is needed for this method + extent = (np.min(coords[:, 0]), np.max(coords[:, 0]), np.min(coords[:, 1]), np.max(coords[:, 1])) + args.update({'extent':extent}) # Derive the variogram - if subsample_method in ['cdist_equidistant', 'cdist_point']: - # Simple wrapper for the skgstat Variogram function for cdist methods - df = _get_cdist_empirical_variogram(values=values, coords=coords, cdist_method=subsample_method, **kwargs) + # Differentiate between 1 core and several cores for multiple runs + # All runs have random sampling inherent to their subfunctions, so we provide the same input arguments + if nproc == 1: + if verbose: + print('Using 1 core...') + + list_df_run = [] + for i in range(nrun): + + argdict = {'i': i, 'imax': nrun, **args, **kwargs} + df_run = _wrapper_get_empirical_variogram(argdict=argdict) + + list_df_run.append(df_run) else: - # Aggregating several skgstat Variogram after iterative subsampling of specific points in the Raster - df = _aggregate_pdist_empirical_variogram(values=values, coords=coords, gsd=gsd, shape=(nx,ny), maxlag=maxlag, - subsample_method=subsample_method, multi_ranges=pdist_multi_ranges, nproc=nproc, - nrun=nrun, subsample=subsample, **kwargs) + if verbose: + print('Using ' + str(nproc) + ' cores...') + + pool = mp.Pool(nproc, maxtasksperchild=1) + argdict = [{'i': i, 'imax': nrun, **args, **kwargs} for i in range(nrun)] + list_df_run = pool.map(_wrapper_get_empirical_variogram, argdict, chunksize=1) + pool.close() + pool.join() + + # Aggregate multiple ranges subsampling + df = pd.concat(list_df_run) + + # For a single run, no multi-run sigma estimated + if nrun == 1: + df['err_exp'] = np.nan + # For several runs, group results, use mean as empirical variogram, estimate sigma, and sum the counts + else: + df_grouped = df.groupby('bins', dropna=False) + df_mean = df_grouped[['exp']].mean() + df_std = df_grouped[['exp']].std() + df_count = df_grouped[['count']].sum() + df_mean['bins'] = df_mean.index.values + df_mean['err_exp'] = df_std['exp'] + df_mean['count'] = df_count['count'] + df = df_mean return df From 533357314521618367d4e22e4f7765689324c428 Mon Sep 17 00:00:00 2001 From: rhugonne Date: Fri, 23 Jul 2021 14:03:22 +0200 Subject: [PATCH 065/113] more robust variogram tests, fix small issues --- tests/test_spatialstats.py | 176 ++++++++++++++++++++++++------------- xdem/spatial_tools.py | 4 +- xdem/spatialstats.py | 139 +++++++++++++++++------------ 3 files changed, 201 insertions(+), 118 deletions(-) diff --git a/tests/test_spatialstats.py b/tests/test_spatialstats.py index 375b517f..b5769f58 100644 --- a/tests/test_spatialstats.py +++ b/tests/test_spatialstats.py @@ -31,112 +31,170 @@ def load_ref_and_diff() -> tuple[gu.georaster.Raster, gu.georaster.Raster, np.nd class TestVariogram: - @pytest.mark.skip("This test fails randomly! It needs to be fixed.") - def test_empirical_fit_variogram_running(self): + def test_sample_multirange_variogram_default(self): + """Verify that the default function runs, and its basic output""" # Load data diff, mask = load_ref_and_diff()[1:3] - x, y = diff.coords(offset='center') - coords = np.dstack((x.flatten(), y.flatten())).squeeze() - - # Check the base script runs with right input shape + # Check the variogram estimation runs for a random state df = xdem.spatialstats.sample_multirange_variogram( - values=diff.data, - gsd=diff.res[0], - subsample=100) - - # check the wrapper script runs with various inputs - # with gsd as input - df_gsd = xdem.spatialstats.sample_multirange_variogram( - values=diff.data, - gsd=diff.res[0], - nsamp=10) + values=diff.data, gsd=diff.res[0], subsample=50, + random_state=42, runs=10) - # with coords as input, and "uniform" bin_func - df_coords = xdem.spatialstats.sample_multirange_variogram( - values=diff.data.flatten(), - coords=coords, - bin_func='uniform', - nsamp=1000) + # With random state, results should always be the same + assert df.exp[0] == pytest.approx(15.4, 0.01) + # With a single run, no error can be estimated + assert all(np.isnan(df.err_exp.values)) - # using more bins - df_1000_bins = xdem.spatialstats.sample_multirange_variogram( - values=diff.data, - gsd=diff.res[0], - n_lags=1000, - nsamp=1000) + # Test multiple runs + df2 = xdem.spatialstats.sample_multirange_variogram( + values=diff.data, gsd=diff.res[0], subsample=50, + random_state=42, runs=10, nrun=2) - # using multiple runs with parallelized function - df_sig = xdem.spatialstats.sample_multirange_variogram(values=diff.data, gsd=diff.res[0], nsamp=1000, - nrun=20, nproc=10, maxlag=10000) + # Check that an error is estimated + assert all(~np.isnan(df2.err_exp.values)) - # test plotting + # Test plotting of empirical variogram by itself if PLOT: - xdem.spatialstats.plot_vgm(df_sig) + xdem.spatialstats.plot_vgm(df2) - # single model fit - fun, _ = xdem.spatialstats.fit_model_sum_vgm(['Sph'], df_sig) - if PLOT: - xdem.spatialstats.plot_vgm(df_sig, list_fit_fun=[fun]) - - try: - # triple model fit - fun2, _ = xdem.spatialstats.fit_model_sum_vgm(['Sph', 'Sph', 'Sph'], emp_vgm_df=df_sig) - if PLOT: - xdem.spatialstats.plot_vgm(df_sig, list_fit_fun=[fun2]) - except RuntimeError as exception: - if "The maximum number of function evaluations is exceeded." not in str(exception): - raise exception - warnings.warn(str(exception)) + @pytest.mark.parametrize('subsample_method',['pdist_point','pdist_ring','pdist_disk','cdist_equidistant','cdist_point']) + def test_sample_multirange_variogram_methods(self, subsample_method): + """Verify that all methods run""" + + # Load data + diff, mask = load_ref_and_diff()[1:3] + + # Check the variogram estimation runs for several methods + df = xdem.spatialstats.sample_multirange_variogram( + values=diff.data, gsd=diff.res[0], subsample=50, random_state=42, + subsample_method=subsample_method) + + assert not df.empty + + def test_sample_multirange_variogram_args(self): + """Verify that optional parameters run only for their specific method, raise warning otherwise""" + + # Load data + diff, mask = load_ref_and_diff()[1:3] + + pdist_args = {'pdist_multi_ranges':[0, diff.res[0]*5, diff.res[0]*10]} + cdist_args = {'ratio_subsample': 0.5} + nonsense_args = {'thisarg': 'shouldnotexist'} + + # Check the function raises a warning for optional arguments incorrect to the method + with pytest.warns(UserWarning): + # An argument only use by cdist with a pdist method + df = xdem.spatialstats.sample_multirange_variogram( + values=diff.data, gsd=diff.res[0], subsample=50, random_state=42, + subsample_method='pdist_ring', **cdist_args) + + with pytest.warns(UserWarning): + # Same here + df = xdem.spatialstats.sample_multirange_variogram( + values=diff.data, gsd=diff.res[0], subsample=50, random_state=42, + subsample_method='cdist_equidistant', **pdist_args) + + with pytest.warns(UserWarning): + # Should also raise a warning for a nonsense argument + df = xdem.spatialstats.sample_multirange_variogram( + values=diff.data, gsd=diff.res[0], subsample=50, random_state=42, + subsample_method='cdist_equidistant', **nonsense_args) + + # Check the function passes optional arguments specific to pdist methods without warning + df = xdem.spatialstats.sample_multirange_variogram( + values=diff.data, gsd=diff.res[0], subsample=50, random_state=42, + subsample_method='pdist_ring', **pdist_args) + + # Check the function passes optional arguments specific to cdist methods without warning + df = xdem.spatialstats.sample_multirange_variogram( + values=diff.data, gsd=diff.res[0], subsample=50, random_state=42, + subsample_method='cdist_equidistant', **cdist_args) def test_multirange_fit_performance(self): + """Verify that the fitting works with artificial dataset""" - # first, generate a true sum of variograms with some added noise - r1, ps1, r2, ps2, r3, ps3 = (100, 0.7, 1000, 0.2, 10000, 0.1) + # First, generate a sum of modelled variograms: ranges and partial sills for three models + params_real = (100, 0.7, 1000, 0.2, 10000, 0.1) + r1, ps1, r2, ps2, r3, ps3 = params_real x = np.linspace(10, 20000, 500) y = models.spherical(x, r=r1, c0=ps1) + models.spherical(x, r=r2, c0=ps2) \ + models.spherical(x, r=r3, c0=ps3) + # Add some noise on top of it sig = 0.025 + np.random.seed(42) y_noise = np.random.normal(0, sig, size=len(x)) y_simu = y + y_noise sigma = np.ones(len(x))*sig + # Put all in a dataframe df = pd.DataFrame() - df = df.assign(bins=x, exp=y_simu, exp_sigma=sig) + df = df.assign(bins=x, exp=y_simu, err_exp=sigma) + + # Run the fitting + fun, params_est = xdem.spatialstats.fit_model_sum_vgm(['Sph', 'Sph', 'Sph'], df) - # then, run the fitting - fun, params = xdem.spatialstats.fit_model_sum_vgm(['Sph', 'Sph', 'Sph'], df) + for i in range(len(params_est)): + # Assert all parameters were correctly estimated within a 30% relative margin + assert params_real[i] == pytest.approx(params_est[i],rel=0.3) if PLOT: - xdem.spatialstats.plot_vgm(df, fit_fun=fun) + xdem.spatialstats.plot_vgm(df, list_fit_fun=[fun]) - def test_neff_estimation(self): + def test_empirical_fit_plotting(self): + """Verify that the shape of the empirical variogram output works with the fit and plotting""" - # test the precision of numerical integration for several spherical models + # Load data + diff, mask = load_ref_and_diff()[1:3] + + # Check the variogram estimation runs for a random state + df = xdem.spatialstats.sample_multirange_variogram( + values=diff.data, + gsd=diff.res[0], + subsample=50, random_state=42, runs=10) + + # Single model fit + fun, _ = xdem.spatialstats.fit_model_sum_vgm(['Sph'], df) + if PLOT: + xdem.spatialstats.plot_vgm(df, list_fit_fun=[fun]) + + # Triple model fit + fun2, _ = xdem.spatialstats.fit_model_sum_vgm(['Sph', 'Sph', 'Sph'], emp_vgm_df=df) + if PLOT: + xdem.spatialstats.plot_vgm(df, list_fit_fun=[fun2]) + + + def test_neff_estimation(self): + """ Test the precision of numerical integration for several spherical models at different scales """ - # short range + # Short ranges crange1 = [10**i for i in range(8)] - # long range + # Long ranges crange2 = [100*sr for sr in crange1] + # Partial sills p1 = 0.8 p2 = 0.2 + # Run for all ranges for r1 in crange1: r2 = crange2[crange1.index(r1)] - # and for any glacier area + # And for a wide range of surface areas for area in [10**i for i in range(10)]: + # Exact integration neff_circ_exact = xdem.spatialstats.exact_neff_sphsum_circular( area=area, crange1=r1, psill1=p1, crange2=r2, psill2=p2) + # Numerical integration neff_circ_numer = xdem.spatialstats.neff_circ(area, [(r1, 'Sph', p1), (r2, 'Sph', p2)]) - assert np.abs(neff_circ_exact-neff_circ_numer) < 0.001 + # Check results are the same + assert neff_circ_exact == pytest.approx(neff_circ_numer, 0.001) class TestSubSampling: diff --git a/xdem/spatial_tools.py b/xdem/spatial_tools.py index 1b7b90b9..f306a9ee 100644 --- a/xdem/spatial_tools.py +++ b/xdem/spatial_tools.py @@ -437,7 +437,7 @@ def subdivide_array(shape: tuple[int, ...], count: int) -> np.ndarray: def subsample_raster( array: Union[np.ndarray, np.ma.masked_array], subsample: Union[float, int], return_indices: bool = False, - random_state : None | np.random.RandomState | int = None) -> np.ndarray: + random_state : None | np.random.RandomState | np.random.Generator | int = None) -> np.ndarray: """ Randomly subsample a 1D or 2D array by a subsampling factor, taking only non NaN/masked values. @@ -451,7 +451,7 @@ def subsample_raster( # Define state for random subsampling (to fix results during testing) if random_state is None: rnd = np.random.default_rng() - elif isinstance(random_state, np.random.RandomState): + elif isinstance(random_state, (np.random.RandomState, np.random.Generator)): rnd = random_state else: rnd = np.random.RandomState(np.random.MT19937(np.random.SeedSequence(random_state))) diff --git a/xdem/spatialstats.py b/xdem/spatialstats.py index c4e813f6..0a459972 100644 --- a/xdem/spatialstats.py +++ b/xdem/spatialstats.py @@ -308,8 +308,8 @@ def create_ring_mask(shape: Union[int, Sequence[int]], center: Optional[list[flo def _subsample_wrapper(values: np.ndarray, coords: np.ndarray, shape: tuple[int,int] = None, subsample: int = 10000, - subsample_method: str = 'pdist_ring', random_state: None | np.random.RandomState | int = None, - inside_radius = None, outside_radius = None) -> tuple[np.ndarray, np.ndarray]: + subsample_method: str = 'pdist_ring', inside_radius = None, outside_radius = None, + random_state: None | np.random.RandomState | np.random.Generator | int = None) -> tuple[np.ndarray, np.ndarray]: """ (Not used by default) Wrapper for subsampling pdist methods @@ -319,7 +319,7 @@ def _subsample_wrapper(values: np.ndarray, coords: np.ndarray, shape: tuple[int, # Define state for random subsampling (to fix results during testing) if random_state is None: rnd = np.random.default_rng() - elif isinstance(random_state, np.random.RandomState): + elif isinstance(random_state, (np.random.RandomState, np.random.Generator)): rnd = random_state else: rnd = np.random.RandomState(np.random.MT19937(np.random.SeedSequence(random_state))) @@ -329,14 +329,13 @@ def _subsample_wrapper(values: np.ndarray, coords: np.ndarray, shape: tuple[int, # Select random center coordinates center_x = rnd.choice(nx, 1)[0] center_y = rnd.choice(ny, 1)[0] - if subsample_method == 'pdist_disk': - index_ring = create_ring_mask((nx, ny), center=[center_x, center_y], in_radius=inside_radius, + if subsample_method == 'pdist_ring': + subindex = create_ring_mask((nx, ny), center=[center_x, center_y], in_radius=inside_radius, out_radius=outside_radius) - index = index_ring.ravel() else: - index_disk = create_circular_mask((nx, ny), center=[center_x, center_y], radius=inside_radius) - index = index_disk.ravel() + subindex = create_circular_mask((nx, ny), center=[center_x, center_y], radius=inside_radius) + index = subindex.flatten() values_sp = values[index] coords_sp = coords[index, :] @@ -345,34 +344,22 @@ def _subsample_wrapper(values: np.ndarray, coords: np.ndarray, shape: tuple[int, coords_sp = coords index = subsample_raster(values_sp, subsample=subsample, return_indices=True, random_state=rnd) - values_sub = values_sp[index] - coords_sub = coords_sp[index, :] + values_sub = values_sp[index[0]] + coords_sub = coords_sp[index[0], :] return values_sub, coords_sub def _aggregate_pdist_empirical_variogram(values: np.ndarray, coords: np.ndarray, subsample: int, shape: tuple, - subsample_method: str, pdist_multi_ranges: Optional[list[float]] = None, - **kwargs) -> pd.DataFrame: + subsample_method: str, gsd: float, + pdist_multi_ranges: Optional[list[float]] = None, **kwargs) -> pd.DataFrame: """ (Not used by default) Aggregating subfunction of sample_multirange_variogram for pdist methods. The pairwise differences are calculated within each subsample. """ - # Get the ground sampling distance - gsd = np.mean([coords[0, 0] - coords[0, 1], coords[0, 0] - coords[1, 0]]) - - # Define maxlag automatically if not done - if 'maxlag' not in kwargs.keys(): - # define maximum lag as the maximum distance between coordinates (needed to provide custom bins, otherwise - # skgstat rewrites the maxlag with the subsample of coordinates provided) - maxlag = np.sqrt((shape[0] * gsd) ** 2 + (shape[1] * gsd) ** 2) - # also need a cutoff value to get the exact same bins - kwargs.update({'maxlag': maxlag}) - else: - maxlag = kwargs.get('maxlag') # If no multi_ranges are provided, define a logical default behaviour with the pixel size and grid size - if subsample_method in ['random_disk', 'random_ring']: + if subsample_method in ['pdist_disk', 'pdist_ring']: if pdist_multi_ranges is None: @@ -380,19 +367,21 @@ def _aggregate_pdist_empirical_variogram(values: np.ndarray, coords: np.ndarray, pdist_multi_ranges = [] # We start at 10 times the ground sampling distance new_range = gsd * 10 - while new_range < maxlag / 2: + while new_range < kwargs.get('maxlag') / 2: pdist_multi_ranges.append(new_range) new_range *= 2 - pdist_multi_ranges.append(maxlag) + pdist_multi_ranges.append(kwargs.get('maxlag')) + # Define subsampling parameters list_inside_radius, list_outside_radius = ([] for i in range(2)) binned_ranges = [0] + pdist_multi_ranges for i in range(len(binned_ranges) - 1): - outside_radius = binned_ranges[i + 1] + 5 * gsd - if subsample_method == 'random_ring': - inside_radius = binned_ranges[i] + # Radiuses need to be passed as pixel sizes, dividing by ground sampling distance + outside_radius = binned_ranges[i + 1]/gsd + if subsample_method == 'pdist_ring': + inside_radius = binned_ranges[i]/gsd else: inside_radius = None @@ -400,7 +389,7 @@ def _aggregate_pdist_empirical_variogram(values: np.ndarray, coords: np.ndarray, list_inside_radius.append(inside_radius) else: # For random point selection, no need for multi-range parameters - pdist_multi_ranges = [maxlag] + pdist_multi_ranges = [kwargs.get('maxlag')] list_outside_radius = [None] list_inside_radius = [None] @@ -411,7 +400,8 @@ def _aggregate_pdist_empirical_variogram(values: np.ndarray, coords: np.ndarray, values_sub, coords_sub = _subsample_wrapper(values, coords, shape = shape, subsample = subsample, subsample_method = subsample_method, inside_radius = list_inside_radius[j], - outside_radius = list_outside_radius[j]) + outside_radius = list_outside_radius[j], + random_state= kwargs.get('random_state')) if len(values_sub) == 0: continue df_range = _get_pdist_empirical_variogram(values=values_sub, coords=coords_sub, **kwargs) @@ -433,6 +423,10 @@ def _get_pdist_empirical_variogram(values: np.ndarray, coords: np.ndarray, **kwa :return: empirical variogram (variance, lags, counts) """ + + # Remove random_state keyword argument that is not used + kwargs.pop('random_state') + # Get arguments of Variogram class init function vgm_args = skg.Variogram.__init__.__code__.co_varnames[:skg.Variogram.__init__.__code__.co_argcount] # Check no other argument is left to be passed @@ -511,12 +505,12 @@ def _get_cdist_empirical_variogram(values: np.ndarray, coords: np.ndarray, subsa return df + def _wrapper_get_empirical_variogram(argdict: dict) -> pd.DataFrame: """ Multiprocessing wrapper for get_pdist_empirical_variogram and get_cdist_empirical variogram :param argdict: Keyword argument to pass to get_pdist/cdist_empirical_variogram - :return: empirical variogram (variance, lags, counts) """ @@ -537,7 +531,8 @@ def _wrapper_get_empirical_variogram(argdict: dict) -> pd.DataFrame: def sample_multirange_variogram(values: Union[np.ndarray,RasterType], gsd: float = None, coords: np.ndarray = None, subsample: int = 10000, subsample_method: str = 'cdist_equidistant', nrun: int = 1, - nproc: int = 1, verbose=False, random_state: None | np.random.RandomState | int = None, + nproc: int = 1, verbose=False, + random_state: None | np.random.RandomState | np.random.Generator | int = None, **kwargs) -> pd.DataFrame: """ Sample empirical variograms with binning adaptable to multiple ranges and spatial subsampling adapted for raster data. @@ -548,8 +543,9 @@ def sample_multirange_variogram(values: Union[np.ndarray,RasterType], gsd: float Those disk and rings are sampled several times across the grid using random centers. If values are provided as a Raster subclass, nothing else is required. - If values are provided as a 2D array (M,N), a ground sampling distance is sufficient to derive the distances. - If values are provided as a 1D array (N), an array of coordinates (N,2) or (2,N) is expected. + If values are provided as a 2D array (M,N), a ground sampling distance is sufficient to derive the pairwise distances. + If values are provided as a 1D array (N), an array of coordinates (N,2) or (2,N) is expected. If the coordinates + do not correspond to all points of the grid, a ground sampling distance is needed to correctly get the grid size. Spatial subsampling method argument subsample_method can be one of "cdist_equidistant", "cdist_point", "pdist_point", "pdist_disk" and "pdist_ring". @@ -565,8 +561,8 @@ def sample_multirange_variogram(values: Union[np.ndarray,RasterType], gsd: float If the subsampling method selected is "random_point", the multi-range argument is ignored as range has no effect on this subsampling method. - For pdist methods, additional keyword arguments are passed to skgstat.Variogram. - For cdist methods, keyword arguments are divided between skgstat.Variogram and skgstat.MetricSpace. + For pdist methods, keyword arguments are passed to skgstat.Variogram. + For cdist methods, keyword arguments are passed to both skgstat.Variogram and skgstat.MetricSpace. :param values: values :param gsd: ground sampling distance @@ -599,46 +595,75 @@ def sample_multirange_variogram(values: Union[np.ndarray,RasterType], gsd: float elif coords is not None and (coords.shape[0] != 2 and coords.shape[1] != 2): raise TypeError('The coordinates array must have one dimension with length equal to 2') + # Check the subsample method provided exists, otherwise list options if subsample_method not in ['cdist_equidistant','cdist_point','pdist_point','pdist_disk','pdist_ring']: raise TypeError('The subsampling method must be one of "cdist_equidistant, "cdist_point", "pdist_point", ' '"pdist_disk" or "pdist_ring".') + # Check that, for several runs, the binning function is an Iterable, otherwise skgstat might provide variogram + # values over slightly different binnings due to randomly changing subsample maximum lags + if nrun > 1 and 'bin_func' in kwargs.keys() and not isinstance(kwargs.get('bin_func'), Iterable): + warnings.warn('Using a named binning function of scikit-gstat might provide different binnings for each ' + 'independent run. To remediate that issue, pass bin_func as an Iterable of right bin edges, ' + '(or use default bin_func).') # Defaulting to coordinates if those are provided if coords is not None: nx = None ny = None - # Making the shape of coordinates consistent + # Making the shape of coordinates consistent if they are transposed if coords.shape[0] == 2 and coords.shape[1] != 2: coords = np.transpose(coords) - # Otherwise, we use the shape of the array and ground sampling distance to infer relative coordinates (starting at zero) + # If no coordinates provided, we use the shape of the array and the provided ground sampling distance to derive + # relative coordinates (starting at zero) else: nx, ny = np.shape(values) x, y = np.meshgrid(np.arange(0, values.shape[0] * gsd, gsd), np.arange(0, values.shape[1] * gsd, gsd)) coords = np.dstack((x.flatten(), y.flatten())).squeeze() values = values.flatten() - # Keep only valid data - ind_valid = np.isfinite(values) - values = values[ind_valid] - coords = coords[ind_valid, :] + # Get the ground sampling distance from the coordinates before keeping only valid data, if it was not provided + if gsd is None: + gsd = np.mean([coords[0, 0] - coords[0, 1], coords[0, 0] - coords[1, 0]]) + # Get extent + extent = (np.min(coords[:, 0]), np.max(coords[:, 0]), np.min(coords[:, 1]), np.max(coords[:, 1])) - # TODO: right now this is a bit useless, as it is the same as in skgstat: adapt for grid data - # Default value we want to use if no binning function, number of lags, and maximum lags are defined - if 'bin_func' not in kwargs.keys(): - kwargs.update({'bin_func': 'even'}) - if 'n_lags' not in kwargs.keys(): - kwargs.update({'n_lags': 10}) + # Get the maximum lag from the coordinates before keeping only valid data, if it was not provided + if 'maxlag' not in kwargs.keys(): + # We define maximum lag as the maximum distance between coordinates (needed to provide custom bins, otherwise + # skgstat rewrites the maxlag with the subsample of coordinates provided) + maxlag = np.sqrt((np.max(coords[:, 0])-np.min(coords[:, 1]))**2 + + (np.max(coords[:, 1]) - np.min(coords[:, 1]))**2) + kwargs.update({'maxlag': maxlag}) + + # Keep only valid data for cdist methods, remove later for pdist methods + if 'cdist' in subsample_method: + ind_valid = np.isfinite(values) + values = values[ind_valid] + coords = coords[ind_valid, :] - # Prepare necessary arguments to pass to variogram functions + if 'bin_func' not in kwargs.keys(): + # If no bin_func is provided, we provide an Iterable to provide a custom binning function to skgstat, + # because otherwise bins might be unconsistent across runs + bin_func = [] + right_bin_edge = 2 * gsd + while right_bin_edge < kwargs.get('maxlag'): + bin_func.append(right_bin_edge) + # We use the default exponential increasing factor of RasterEquidistantMetricSpace, adapted for grids + right_bin_edge *= np.sqrt(2) + bin_func.append(kwargs.get('maxlag')) + kwargs.update({'bin_func': bin_func}) + + # Prepare necessary arguments to pass to variogram subfunctions args = {'values': values, 'coords': coords, 'subsample_method': subsample_method, 'subsample': subsample, 'verbose': verbose, 'random_state': random_state} - if subsample_method in ['cdist_equidistant','pdist_ring','pdist_disk']: + if subsample_method in ['cdist_equidistant','pdist_ring','pdist_disk', 'pdist_point']: # The shape is needed for those three methods args.update({'shape': (nx, ny)}) if subsample_method == 'cdist_equidistant': # The coordinate extent is needed for this method - extent = (np.min(coords[:, 0]), np.max(coords[:, 0]), np.min(coords[:, 1]), np.max(coords[:, 1])) args.update({'extent':extent}) + else: + args.update({'gsd': gsd}) # Derive the variogram # Differentiate between 1 core and several cores for multiple runs @@ -739,14 +764,14 @@ def vgm_sum(h, *args): bounds = np.transpose(np.array(bounds)) - if np.all(np.isnan(emp_vgm_df.exp_sigma.values)): + if np.all(np.isnan(emp_vgm_df.err_exp.values)): valid = ~np.isnan(emp_vgm_df.exp.values) cof, cov = curve_fit(vgm_sum, emp_vgm_df.bins.values[valid], emp_vgm_df.exp.values[valid], method='trf', p0=p0, bounds=bounds) else: - valid = np.logical_and(~np.isnan(emp_vgm_df.exp.values), ~np.isnan(emp_vgm_df.exp_sigma.values)) + valid = np.logical_and(~np.isnan(emp_vgm_df.exp.values), ~np.isnan(emp_vgm_df.err_exp.values)) cof, cov = curve_fit(vgm_sum, emp_vgm_df.bins.values[valid], emp_vgm_df.exp.values[valid], - method='trf', p0=p0, bounds=bounds, sigma=emp_vgm_df.exp_sigma.values[valid]) + method='trf', p0=p0, bounds=bounds, sigma=emp_vgm_df.err_exp.values[valid]) # rewriting the output function: couldn't find a way to pass this with functool.partial because arguments are unordered def vgm_sum_fit(h): @@ -1184,7 +1209,7 @@ def plot_vgm(df: pd.DataFrame, list_fit_fun: Optional[list[Callable[[float],floa if np.all(np.isnan(df.exp_sigma)): ax.scatter(df.bins, df.exp, label='Empirical variogram', color='blue') else: - ax.errorbar(df.bins, df.exp, yerr=df.exp_sigma, label='Empirical variogram (1-sigma s.d)') + ax.errorbar(df.bins, df.exp, yerr=df.err_exp, label='Empirical variogram (1-sigma s.d)') if list_fit_fun is not None: for i, fit_fun in enumerate(list_fit_fun): From d2c10bac43bde61c66eec192b8754b554448cae5 Mon Sep 17 00:00:00 2001 From: rhugonne Date: Fri, 23 Jul 2021 14:09:35 +0200 Subject: [PATCH 066/113] other merge commit left behind --- xdem/spatialstats.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/xdem/spatialstats.py b/xdem/spatialstats.py index 0a459972..8a067f53 100644 --- a/xdem/spatialstats.py +++ b/xdem/spatialstats.py @@ -271,7 +271,9 @@ def create_circular_mask(shape: Union[int, Sequence[int]], center: Optional[list # skimage disk is not inclusive (correspond to distance_from_center < radius and not <= radius) mask = np.zeros(shape, dtype=bool) - rr, cc = disk(center=center,radius=radius,shape=shape) + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", "invalid value encountered in true_divide") + rr, cc = disk(center=center,radius=radius,shape=shape) mask[rr, cc] = True # manual solution @@ -299,8 +301,10 @@ def create_ring_mask(shape: Union[int, Sequence[int]], center: Optional[list[flo center = (int(w / 2), int(h / 2)) out_radius = min(center[0], center[1], w - center[0], h - center[1]) - mask_inside = create_circular_mask((w,h),center=center,radius=in_radius) - mask_outside = create_circular_mask((w,h),center=center,radius=out_radius) + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", "invalid value encountered in true_divide") + mask_inside = create_circular_mask((w,h),center=center,radius=in_radius) + mask_outside = create_circular_mask((w,h),center=center,radius=out_radius) mask_ring = np.logical_and(~mask_inside,mask_outside) From 3a2d8439944c26ff410e31a209071d174b225fa8 Mon Sep 17 00:00:00 2001 From: rhugonne Date: Fri, 23 Jul 2021 14:21:51 +0200 Subject: [PATCH 067/113] reduce test length --- tests/test_spatialstats.py | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/tests/test_spatialstats.py b/tests/test_spatialstats.py index f772ca9b..43316c77 100644 --- a/tests/test_spatialstats.py +++ b/tests/test_spatialstats.py @@ -40,28 +40,28 @@ def test_sample_multirange_variogram_default(self): # Check the variogram estimation runs for a random state df = xdem.spatialstats.sample_multirange_variogram( values=diff.data, gsd=diff.res[0], subsample=50, - random_state=42, runs=10) + random_state=42, runs=2) # With random state, results should always be the same - assert df.exp[0] == pytest.approx(15.4, 0.01) + assert df.exp[0] == pytest.approx(12.76, 0.01) # With a single run, no error can be estimated assert all(np.isnan(df.err_exp.values)) # Test multiple runs df2 = xdem.spatialstats.sample_multirange_variogram( values=diff.data, gsd=diff.res[0], subsample=50, - random_state=42, runs=10, nrun=2) + random_state=42, runs=2, nrun=2) # Check that an error is estimated - assert all(~np.isnan(df2.err_exp.values)) + assert any(~np.isnan(df2.err_exp.values)) # Test plotting of empirical variogram by itself if PLOT: xdem.spatialstats.plot_vgm(df2) - @pytest.mark.parametrize('subsample_method',['pdist_point','pdist_ring','pdist_disk','cdist_equidistant','cdist_point']) + @pytest.mark.parametrize('subsample_method',['pdist_point','pdist_ring','pdist_disk','cdist_point']) def test_sample_multirange_variogram_methods(self, subsample_method): - """Verify that all methods run""" + """Verify that all other methods run""" # Load data diff, mask = load_ref_and_diff()[1:3] @@ -94,13 +94,13 @@ def test_sample_multirange_variogram_args(self): # Same here df = xdem.spatialstats.sample_multirange_variogram( values=diff.data, gsd=diff.res[0], subsample=50, random_state=42, - subsample_method='cdist_equidistant', **pdist_args) + subsample_method='cdist_equidistant', runs=2, **pdist_args) with pytest.warns(UserWarning): # Should also raise a warning for a nonsense argument df = xdem.spatialstats.sample_multirange_variogram( values=diff.data, gsd=diff.res[0], subsample=50, random_state=42, - subsample_method='cdist_equidistant', **nonsense_args) + subsample_method='cdist_equidistant', runs=2, **nonsense_args) # Check the function passes optional arguments specific to pdist methods without warning df = xdem.spatialstats.sample_multirange_variogram( @@ -110,7 +110,7 @@ def test_sample_multirange_variogram_args(self): # Check the function passes optional arguments specific to cdist methods without warning df = xdem.spatialstats.sample_multirange_variogram( values=diff.data, gsd=diff.res[0], subsample=50, random_state=42, - subsample_method='cdist_equidistant', **cdist_args) + subsample_method='cdist_equidistant', runs=2, **cdist_args) def test_multirange_fit_performance(self): """Verify that the fitting works with artificial dataset""" @@ -153,9 +153,7 @@ def test_empirical_fit_plotting(self): # Check the variogram estimation runs for a random state df = xdem.spatialstats.sample_multirange_variogram( - values=diff.data, - gsd=diff.res[0], - subsample=50, random_state=42, runs=10) + values=diff.data, gsd=diff.res[0], subsample=50, random_state=42, runs=10) # Single model fit fun, _ = xdem.spatialstats.fit_model_sum_vgm(['Sph'], df) From 6e699417329f053aba21a8cb6422a46a256d4490 Mon Sep 17 00:00:00 2001 From: rhugonne Date: Fri, 23 Jul 2021 17:02:26 +0200 Subject: [PATCH 068/113] improve plot_vgm --- examples/plot_vgm_error.py | 38 ++++++++++++++------------- xdem/spatialstats.py | 54 +++++++++++++++++++++++++++++--------- 2 files changed, 62 insertions(+), 30 deletions(-) diff --git a/examples/plot_vgm_error.py b/examples/plot_vgm_error.py index 540e5bea..3c76556a 100644 --- a/examples/plot_vgm_error.py +++ b/examples/plot_vgm_error.py @@ -36,55 +36,57 @@ # Prior to differencing, the DEMs were aligned using :class:`xdem.coreg.NuthKaab` as shown in # the :ref:`sphx_glr_auto_examples_plot_nuth_kaab.py` example. We later refer to those elevation differences as *dh*. -ddem = xdem.DEM(xdem.examples.get_path("longyearbyen_ddem")) +dh = xdem.DEM(xdem.examples.get_path("longyearbyen_ddem")) glacier_outlines = gu.Vector(xdem.examples.get_path("longyearbyen_glacier_outlines")) -mask_glacier = glacier_outlines.create_mask(ddem) +mask_glacier = glacier_outlines.create_mask(dh) # %% # We remove values on glacier terrain -ddem.data[mask_glacier] = np.nan +dh.data[mask_glacier] = np.nan # %% # Let's plot the elevation differences plt.figure(figsize=(8, 5)) plt_extent = [ - ddem.bounds.left, - ddem.bounds.right, - ddem.bounds.bottom, - ddem.bounds.top, + dh.bounds.left, + dh.bounds.right, + dh.bounds.bottom, + dh.bounds.top, ] -plt.imshow(ddem.data.squeeze(), cmap="RdYlBu", vmin=-4, vmax=4, extent=plt_extent) +plt.imshow(dh.data.squeeze(), cmap="RdYlBu", vmin=-4, vmax=4, extent=plt_extent) cbar = plt.colorbar() cbar.set_label('Elevation differences (m)') plt.show() # %% -# We can see that the elevation difference is still polluted by unmasked glaciers, let's filter large outliers outside 4 NMAD -ddem.data[np.abs(ddem.data) > 4 * xdem.spatialstats.nmad(ddem.data)] = np.nan +# We can see that the elevation differences are still polluted by unmasked glaciers: let's filter outliers outside 4 NMAD +dh.data[np.abs(dh.data) > 4 * xdem.spatialstats.nmad(dh.data)] = np.nan # %% # Let's plot the elevation differences after filtering plt.figure(figsize=(8, 5)) plt_extent = [ - ddem.bounds.left, - ddem.bounds.right, - ddem.bounds.bottom, - ddem.bounds.top, + dh.bounds.left, + dh.bounds.right, + dh.bounds.bottom, + dh.bounds.top, ] -plt.imshow(ddem.data.squeeze(), cmap="RdYlBu", vmin=-4, vmax=4, extent=plt_extent) +plt.imshow(dh.data.squeeze(), cmap="RdYlBu", vmin=-4, vmax=4, extent=plt_extent) cbar = plt.colorbar() cbar.set_label('Elevation differences (m)') plt.show() # %% # Sample empirical variogram -df = xdem.spatialstats.sample_multirange_variogram(dh=ddem.data, gsd=ddem.res[0], nsamp=2000, nrun=100, maxlag=20000) +df = xdem.spatialstats.sample_multirange_variogram( + values=dh.data, gsd=dh.res[0], subsample=50, runs=100, nrun=10) # Plot empirical variogram +# fig, ax = plt.subplots() xdem.spatialstats.plot_vgm(df) -fun, _ = xdem.spatialstats.fit_model_sum_vgm(['Sph'], df) -fun2, _ = xdem.spatialstats.fit_model_sum_vgm(['Sph', 'Sph', 'Sph'], emp_vgm_df=df) +# fun, _ = xdem.spatialstats.fit_model_sum_vgm(['Sph'], df) +# fun2, _ = xdem.spatialstats.fit_model_sum_vgm(['Sph', 'Sph', 'Sph'], emp_vgm_df=df) diff --git a/xdem/spatialstats.py b/xdem/spatialstats.py index 8a067f53..a7fffc2a 100644 --- a/xdem/spatialstats.py +++ b/xdem/spatialstats.py @@ -445,7 +445,7 @@ def _get_pdist_empirical_variogram(values: np.ndarray, coords: np.ndarray, **kwa # Derive variogram with default MetricSpace (equivalent to scipy.pdist) V = skg.Variogram(coordinates=coords, values=values, normalize=False, fit_method=None, **filtered_kwargs) - # Get the middle value of the bins, empirical variogram values, and bin count + # Get bins, empirical variogram values, and bin count bins, exp = V.get_empirical() count = V.bin_count @@ -499,7 +499,7 @@ def _get_cdist_empirical_variogram(values: np.ndarray, coords: np.ndarray, subsa filtered_var_kwargs = {k: kwargs[k] for k in vgm_args if k in kwargs} V = skg.Variogram(M, values=values, normalize=False, fit_method=None, **filtered_var_kwargs) - # Get the middle value of the bins, empirical variogram values, and bin count + # Get bins, empirical variogram values, and bin count bins, exp = V.get_empirical() count = V.bin_count @@ -649,7 +649,7 @@ def sample_multirange_variogram(values: Union[np.ndarray,RasterType], gsd: float # If no bin_func is provided, we provide an Iterable to provide a custom binning function to skgstat, # because otherwise bins might be unconsistent across runs bin_func = [] - right_bin_edge = 2 * gsd + right_bin_edge = np.sqrt(2) * gsd while right_bin_edge < kwargs.get('maxlag'): bin_func.append(right_bin_edge) # We use the default exponential increasing factor of RasterEquidistantMetricSpace, adapted for grids @@ -1188,9 +1188,10 @@ def patches_method(values : np.ndarray, mask: np.ndarray[bool], gsd : float, are def plot_vgm(df: pd.DataFrame, list_fit_fun: Optional[list[Callable[[float],float]]] = None, - list_fit_fun_label: Optional[list[str]] = None, ax: matplotlib.axes.Axes | None = None): + list_fit_fun_label: Optional[list[str]] = None, ax: matplotlib.axes.Axes | None = None, + xscale='linear'): """ - Plot empirical variogram, with optionally one or several model fits. + Plot empirical variogram, and optionally also plot one or several model fits. Input dataframe is expected to be the output of xdem.spatialstats.sample_multirange_variogram. Input function model is expected to be the output of xdem.spatialstats.fit_model_sum_vgm. @@ -1201,20 +1202,49 @@ def plot_vgm(df: pd.DataFrame, list_fit_fun: Optional[list[Callable[[float],floa :return: """ - # Create axes + # Create axes if they are not passed if ax is None: - fig, ax = plt.subplots() + fig = plt.figure() elif isinstance(ax, matplotlib.axes.Axes): ax = ax fig = ax.figure else: raise ValueError("ax must be a matplotlib.axes.Axes instance or None") - if np.all(np.isnan(df.exp_sigma)): - ax.scatter(df.bins, df.exp, label='Empirical variogram', color='blue') + # Need a grid plot to show the sample count and the statistic + grid = plt.GridSpec(10, 10, wspace=0.5, hspace=0.5) + + # First, an axis to plot the sample histogram + ax0 = fig.add_subplot(grid[:3, :]) + ax0.set_xscale(xscale) + ax0.set_xticks([]) + + # Plot the histogram manually with fill_between + interval_var = [0] + list(df.bins) + for i in range(len(df)): + count = df['count'].values[i] + ax0.fill_between([interval_var[i], interval_var[i+1]], [0] * 2, [count] * 2, + facecolor=plt.cm.Greys(0.75), alpha=1, + edgecolor='white', linewidth=0.1) + ax0.set_ylabel('Sample count') + # Scientific format to avoid undesired additional space on the label side + ax0.ticklabel_format(axis='y', style='sci', scilimits=(0, 0)) + ax0.set_xlim((0, np.max(df.bins))) + + # Now, plot the statistic of the data + ax = fig.add_subplot(grid[3:, :]) + + # Get the bins center + bins_center = np.subtract(df.bins, np.diff([0] + df.bins.tolist()) / 2) + + # If all the estimated errors are all NaN (single run), simply plot the empirical variogram + if np.all(np.isnan(df.err_exp)): + ax.scatter(bins_center, df.exp, label='Empirical variogram', color='blue', marker='x') + # Otherwise, plot the error estimates through multiple runs else: - ax.errorbar(df.bins, df.exp, yerr=df.err_exp, label='Empirical variogram (1-sigma s.d)') + ax.errorbar(bins_center, df.exp, yerr=df.err_exp, label='Empirical variogram (1-sigma s.d)', fmt='x') + # If a list of functions is passed, plot the modelled variograms if list_fit_fun is not None: for i, fit_fun in enumerate(list_fit_fun): x = np.linspace(0, np.max(df.bins), 10000) @@ -1231,8 +1261,8 @@ def plot_vgm(df: pd.DataFrame, list_fit_fun: Optional[list[Callable[[float],floa ax.set_xlabel('Lag (m)') ax.set_ylabel(r'Variance [$\mu$ $\pm \sigma$]') ax.legend(loc='best') - - return ax + ax.set_xscale(xscale) + ax.set_xlim((0, np.max(df.bins))) def plot_1d_binning(df: pd.DataFrame, var_name: str, statistic_name: str, label_var: Optional[str] = None, label_statistic: Optional[str] = None, min_count: int = 30, ax: matplotlib.axes.Axes | None = None): From f834082d8723e6cfa693f5a0d175b65013956bca Mon Sep 17 00:00:00 2001 From: rhugonne Date: Fri, 23 Jul 2021 18:02:10 +0200 Subject: [PATCH 069/113] finish skeleton of gallery example --- docs/source/code/spatialstats.py | 54 ++++++++++++++++---------------- examples/plot_vgm_error.py | 52 +++++++++++++++++++++++++++--- 2 files changed, 74 insertions(+), 32 deletions(-) diff --git a/docs/source/code/spatialstats.py b/docs/source/code/spatialstats.py index bb362c5a..058918ba 100644 --- a/docs/source/code/spatialstats.py +++ b/docs/source/code/spatialstats.py @@ -2,33 +2,33 @@ import xdem import geoutils as gu import numpy as np - -# Load data -ddem = gu.georaster.Raster(xdem.examples.get_path("longyearbyen_ddem")) -glacier_mask = gu.geovector.Vector(xdem.examples.get_path("longyearbyen_glacier_outlines")) -mask = glacier_mask.create_mask(ddem) - -# Get slope for non-stationarity -slope = xdem.coreg.calculate_slope_and_aspect(ddem.data)[0] - -# Keep only stable terrain data -ddem.data[mask] = np.nan - -# Get non-stationarities by bins -df_ns = xdem.spatialstats.nd_binning(ddem.data.ravel(), list_var=[slope.ravel()], list_var_names=['slope']) - -# Sample empirical variogram -df_vgm = xdem.spatialstats.sample_multirange_empirical_variogram(dh=ddem.data, nsamp=1000, nrun=20, nproc=10, maxlag=10000) - -# Fit single-range spherical model -fun, coefs = xdem.spatialstats.fit_model_sum_vgm(['Sph'], emp_vgm_df=df_vgm) - -# Fit sum of triple-range spherical model -fun2, coefs2 = xdem.spatialstats.fit_model_sum_vgm(['Sph', 'Sph', 'Sph'], emp_vgm_df=df_vgm) - -# Calculate the area-averaged uncertainty with these models -list_vgm = [(coefs[2*i],'Sph',coefs[2*i+1]) for i in range(int(len(coefs)/2))] -neff = xdem.spatialstats.neff_circ(1, list_vgm) +# +# # Load data +# ddem = gu.georaster.Raster(xdem.examples.get_path("longyearbyen_ddem")) +# glacier_mask = gu.geovector.Vector(xdem.examples.get_path("longyearbyen_glacier_outlines")) +# mask = glacier_mask.create_mask(ddem) +# +# # Get slope for non-stationarity +# slope = xdem.coreg.calculate_slope_and_aspect(ddem.data)[0] +# +# # Keep only stable terrain data +# ddem.data[mask] = np.nan +# +# # Get non-stationarities by bins +# df_ns = xdem.spatialstats.nd_binning(ddem.data.ravel(), list_var=[slope.ravel()], list_var_names=['slope']) +# +# # Sample empirical variogram +# df_vgm = xdem.spatialstats.sample_multirange_empirical_variogram(dh=ddem.data, nsamp=1000, nrun=20, nproc=10, maxlag=10000) +# +# # Fit single-range spherical model +# fun, coefs = xdem.spatialstats.fit_model_sum_vgm(['Sph'], emp_vgm_df=df_vgm) +# +# # Fit sum of triple-range spherical model +# fun2, coefs2 = xdem.spatialstats.fit_model_sum_vgm(['Sph', 'Sph', 'Sph'], emp_vgm_df=df_vgm) +# +# # Calculate the area-averaged uncertainty with these models +# list_vgm = [(coefs[2*i],'Sph',coefs[2*i+1]) for i in range(int(len(coefs)/2))] +# neff = xdem.spatialstats.neff_circ(1, list_vgm) diff --git a/examples/plot_vgm_error.py b/examples/plot_vgm_error.py index 3c76556a..ea804191 100644 --- a/examples/plot_vgm_error.py +++ b/examples/plot_vgm_error.py @@ -24,7 +24,7 @@ measurement errors for this DEM difference. """ -# sphinx_gallery_thumbnail_number = 8 +# sphinx_gallery_thumbnail_number = 5 import matplotlib.pyplot as plt import numpy as np import xdem @@ -80,13 +80,55 @@ # %% # Sample empirical variogram df = xdem.spatialstats.sample_multirange_variogram( - values=dh.data, gsd=dh.res[0], subsample=50, runs=100, nrun=10) + values=dh.data, gsd=dh.res[0], subsample=50, runs=30, nrun=10) # Plot empirical variogram -# fig, ax = plt.subplots() xdem.spatialstats.plot_vgm(df) -# fun, _ = xdem.spatialstats.fit_model_sum_vgm(['Sph'], df) -# fun2, _ = xdem.spatialstats.fit_model_sum_vgm(['Sph', 'Sph', 'Sph'], emp_vgm_df=df) + +# %% +# A lot of things are happening at the + + +# %% +# +fun, params1 = xdem.spatialstats.fit_model_sum_vgm(['Sph'], emp_vgm_df=df) + + +fun2, params2 = xdem.spatialstats.fit_model_sum_vgm(['Sph', 'Sph'], emp_vgm_df=df) +xdem.spatialstats.plot_vgm(df,list_fit_fun=[fun, fun2],list_fit_fun_label=['Single-range model', 'Double-range model'], + xscale='log') + +# %% +# Let's see how this affect the precision of the DEM integrated over a certain surface area, from pixel size to grid size + +# Areas varying from pixel size squared to grid size squared, with same unit as the variogram parameters (meters) +areas = [400*2**i for i in range(20)] + +# Derive the precision for each area +list_stderr_singlerange, list_stderr_doublerange = ([] for i in range(2)) +for area in areas: + + # Number of effective samples integrated over the area for a single-range model + neff_singlerange = xdem.spatialstats.neff_circ(area, [(params1[0], 'Sph', params1[1])]) + + # For a double-range model + neff_doublerange = xdem.spatialstats.neff_circ(area, [(params2[0], 'Sph', params2[1]), + (params2[2], 'Sph', params2[3])]) + + # Convert into a standard error + stderr_singlerange = np.nanstd(dh.data)/np.sqrt(neff_singlerange) + stderr_doublerange = np.nanstd(dh.data)/np.sqrt(neff_doublerange) + + list_stderr_singlerange.append(stderr_singlerange) + list_stderr_doublerange.append(stderr_doublerange) + +fig, ax = plt.subplots() +plt.scatter(np.asarray(areas)/1000000, list_stderr_singlerange, label='Single-range spherical model') +plt.scatter(np.asarray(areas)/1000000, list_stderr_doublerange, label='Double-range spherical model') +plt.xlabel('Averaging area (km²)') +plt.ylabel('Uncertainty in the mean elevation difference (m)') +plt.xscale('log') +plt.legend() From 45f51e6a5229db2acdf1ae3f94da30c2184904aa Mon Sep 17 00:00:00 2001 From: rhugonne Date: Fri, 23 Jul 2021 18:15:30 +0200 Subject: [PATCH 070/113] remove old plotting --- docs/source/code/spatialstats_plot_vgm.py | 27 ----------------------- 1 file changed, 27 deletions(-) delete mode 100644 docs/source/code/spatialstats_plot_vgm.py diff --git a/docs/source/code/spatialstats_plot_vgm.py b/docs/source/code/spatialstats_plot_vgm.py deleted file mode 100644 index b6075214..00000000 --- a/docs/source/code/spatialstats_plot_vgm.py +++ /dev/null @@ -1,27 +0,0 @@ -"""Plot example for variogram""" -import matplotlib.pyplot as plt - -import geoutils as gu -import xdem -import numpy as np - -# load data -ddem = gu.georaster.Raster(xdem.examples.get_path("longyearbyen_ddem")) -glacier_mask = gu.geovector.Vector(xdem.examples.get_path("longyearbyen_glacier_outlines")) -mask = glacier_mask.create_mask(ddem) - -# remove glacier data -ddem.data[mask] = np.nan - -# ensure the figures are reproducible -np.random.seed(42) - -# sample empirical variogram -df = xdem.spatialstats.sample_multirange_empirical_variogram(dh=ddem.data, gsd=ddem.res[0], nsamp=1000, nrun=20, maxlag=4000) - -fun, _ = xdem.spatialstats.fit_model_sum_vgm(['Sph'], df) -fun2, _ = xdem.spatialstats.fit_model_sum_vgm(['Sph', 'Sph', 'Sph'], emp_vgm_df=df) -xdem.spatialstats.plot_vgm(df, list_fit_fun=[fun, fun2], list_fit_fun_label=['Spherical model', 'Sum of three spherical models']) - -plt.show() - From 3689492e9727db90e18d2543fdc5e7e89b3d077a Mon Sep 17 00:00:00 2001 From: rhugonne Date: Fri, 23 Jul 2021 18:19:24 +0200 Subject: [PATCH 071/113] fix test --- tests/test_spatialstats.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_spatialstats.py b/tests/test_spatialstats.py index 43316c77..5134307c 100644 --- a/tests/test_spatialstats.py +++ b/tests/test_spatialstats.py @@ -43,7 +43,7 @@ def test_sample_multirange_variogram_default(self): random_state=42, runs=2) # With random state, results should always be the same - assert df.exp[0] == pytest.approx(12.76, 0.01) + assert df.exp[0] == pytest.approx(6.11, 0.01) # With a single run, no error can be estimated assert all(np.isnan(df.err_exp.values)) From b3dca29968aad2d5af9d68e40a7d99a0ab969742 Mon Sep 17 00:00:00 2001 From: rhugonne Date: Tue, 27 Jul 2021 20:33:20 +0200 Subject: [PATCH 072/113] improve plot_vgm and patches_method --- xdem/spatialstats.py | 275 +++++++++++++++++++++++++++---------------- 1 file changed, 172 insertions(+), 103 deletions(-) diff --git a/xdem/spatialstats.py b/xdem/spatialstats.py index a7fffc2a..5f874cb8 100644 --- a/xdem/spatialstats.py +++ b/xdem/spatialstats.py @@ -4,7 +4,6 @@ import math as m import multiprocessing as mp import os -import random import warnings from functools import partial @@ -1101,85 +1100,100 @@ def double_sum_covar(list_tuple_errs: list[float], corr_ranges: list[float], lis return np.sqrt(var_err) -def patches_method(values : np.ndarray, mask: np.ndarray[bool], gsd : float, area_size : float, perc_min_valid: float = 80., - patch_shape: str = 'circular',nmax : int = 1000, verbose: bool = False) -> pd.DataFrame: +def patches_method(values: np.ndarray, gsd: float, area: float, mask: Optional[np.ndarray] = None, + perc_min_valid: float = 80., patch_shape: str = 'circular', nmax: int = 1000, verbose: bool = False)\ + -> pd.DataFrame: """ Patches method for empirical estimation of the standard error over an integration area :param values: values - :param mask: mask of sampled terrain :param gsd: ground sampling distance - :param area_size: size of integration area + :param mask: mask of sampled terrain + :param area: size of integration area :param perc_min_valid: minimum valid area in the patch :param patch_shape: shape of patch ['circular' or 'rectangular'] :param nmax: maximum number of patch to sample - - #TODO: add overlap option? + :param verbose: print statement to console :return: tile, mean, median, std and count of each patch """ - # first, remove non sampled area (but we need to keep the 2D shape of raster for patch sampling) + # TODO: make robust to Raster inputs, masked arrays, etc... values = values.squeeze() + + # Use all grid if no mask is provided + if mask is None: + mask = np.ones(np.shape(values),dtype=bool) + + # First, remove non sampled area (but we need to keep the 2D shape of raster for patch sampling) valid_mask = np.logical_and(np.isfinite(values), mask) values[~valid_mask] = np.nan - # divide raster in cadrants where we can sample + # Divide raster in cadrants where we can sample nx, ny = np.shape(values) - count = len(values[~np.isnan(values)]) - print('Number of valid pixels: ' + str(count)) - nb_cadrant = int(np.floor(np.sqrt((count * gsd ** 2) / area_size) + 1)) - # rectangular + valid_count = len(values[~np.isnan(values)]) + count = nx * ny + if verbose: + print('Number of valid pixels: ' + str(count)) + nb_cadrant = int(np.floor(np.sqrt((count * gsd ** 2) / area) + 1)) + # For rectangular quadrants nx_sub = int(np.floor((nx - 1) / nb_cadrant)) ny_sub = int(np.floor((ny - 1) / nb_cadrant)) - # radius size for a circular patch - rad = int(np.floor(np.sqrt(area_size/np.pi * gsd ** 2))) + # For circular patches + rad = np.sqrt(area/np.pi) / gsd tile, mean_patch, med_patch, std_patch, nb_patch = ([] for i in range(5)) - # create list of all possible cadrants + # Create list of all possible cadrants list_cadrant = [[i, j] for i in range(nb_cadrant) for j in range(nb_cadrant)] u = 0 - # keep sampling while there is cadrants left and below maximum number of patch to sample + # Keep sampling while there is cadrants left and below maximum number of patch to sample + remaining_nsamp = nmax while len(list_cadrant) > 0 and u < nmax: - check = 0 - while check == 0: - rand_cadrant = random.randint(0, len(list_cadrant)-1) - - i = list_cadrant[rand_cadrant][0] - j = list_cadrant[rand_cadrant][1] + # Draw a random coordinate from the list of cadrants, select more than enough random points to avoid drawing + # randomly and differencing lists several times + list_idx_cadrant = np.random.choice(len(list_cadrant), size=min(len(list_cadrant), 10*remaining_nsamp)) - check_x = int(np.floor(nx_sub*(i+1/2))) - check_y = int(np.floor(ny_sub*(j+1/2))) - if mask[check_x, check_y]: - check = 1 + for idx_cadrant in list_idx_cadrant: - list_cadrant.remove(list_cadrant[rand_cadrant]) - - tile.append(str(i) + '_' + str(j)) - if patch_shape == 'rectangular': - patch = values[nx_sub * i:nx_sub * (i + 1), ny_sub * j:ny_sub * (j + 1)].flatten() - elif patch_shape == 'circular': - center_x = np.floor(nx_sub*(i+1/2)) - center_y = np.floor(ny_sub*(j+1/2)) - mask = create_circular_mask((nx, ny), center=(center_x, center_y), radius=rad) - patch = values[mask] - else: - raise ValueError('Patch method must be rectangular or circular.') - - nb_pixel_total = len(patch) - nb_pixel_valid = len(patch[np.isfinite(patch)]) - if nb_pixel_valid > np.ceil(perc_min_valid / 100. * nb_pixel_total): - u=u+1 if verbose: - print('Found valid cadrant ' + str(u)+ ' (maximum: '+str(nmax)+')') - - mean_patch.append(np.nanmean(patch)) - med_patch.append(np.nanmedian(patch.filled(np.nan) if isinstance(patch, np.ma.masked_array) else patch)) - std_patch.append(np.nanstd(patch)) - nb_patch.append(nb_pixel_valid) + print('Working on a new cadrant') + + # Select center coordinates + i = list_cadrant[idx_cadrant][0] + j = list_cadrant[idx_cadrant][1] + + if patch_shape == 'rectangular': + patch = values[nx_sub * i:nx_sub * (i + 1), ny_sub * j:ny_sub * (j + 1)].flatten() + elif patch_shape == 'circular': + center_x = np.floor(nx_sub*(i+1/2)) + center_y = np.floor(ny_sub*(j+1/2)) + mask = create_circular_mask((nx, ny), center=[center_x, center_y], radius=rad) + patch = values[mask] + else: + raise ValueError('Patch method must be rectangular or circular.') + + nb_pixel_total = len(patch) + nb_pixel_valid = len(patch[np.isfinite(patch)]) + if nb_pixel_valid > np.ceil(perc_min_valid / 100. * nb_pixel_total): + u=u+1 + if u > nmax: + break + if verbose: + print('Found valid cadrant ' + str(u)+ ' (maximum: '+str(nmax)+')') + + tile.append(str(i) + '_' + str(j)) + mean_patch.append(np.nanmean(patch)) + med_patch.append(np.nanmedian(patch.filled(np.nan) if isinstance(patch, np.ma.masked_array) else patch)) + std_patch.append(np.nanstd(patch)) + nb_patch.append(nb_pixel_valid) + + # Get remaining samples to draw + remaining_nsamp = nmax - u + # Remove cadrants already sampled from list + list_cadrant = [c for j, c in enumerate(list_cadrant) if j not in list_idx_cadrant] df = pd.DataFrame() df = df.assign(tile=tile, mean=mean_patch, med=med_patch, std=std_patch, count=nb_patch) @@ -1189,7 +1203,7 @@ def patches_method(values : np.ndarray, mask: np.ndarray[bool], gsd : float, are def plot_vgm(df: pd.DataFrame, list_fit_fun: Optional[list[Callable[[float],float]]] = None, list_fit_fun_label: Optional[list[str]] = None, ax: matplotlib.axes.Axes | None = None, - xscale='linear'): + xscale='linear', xscale_range_split: Optional[list] = None): """ Plot empirical variogram, and optionally also plot one or several model fits. Input dataframe is expected to be the output of xdem.spatialstats.sample_multirange_variogram. @@ -1199,6 +1213,8 @@ def plot_vgm(df: pd.DataFrame, list_fit_fun: Optional[list[Callable[[float],floa :param list_fit_fun: list of model function fits :param list_fit_fun_label: list of model function fits labels :param ax: plotting ax to use, creates a new one by default + :param xscale: scale of x axis + :param xscale_range_split: list of ranges at which to split the figure :return: """ @@ -1211,58 +1227,111 @@ def plot_vgm(df: pd.DataFrame, list_fit_fun: Optional[list[Callable[[float],floa else: raise ValueError("ax must be a matplotlib.axes.Axes instance or None") - # Need a grid plot to show the sample count and the statistic - grid = plt.GridSpec(10, 10, wspace=0.5, hspace=0.5) - - # First, an axis to plot the sample histogram - ax0 = fig.add_subplot(grid[:3, :]) - ax0.set_xscale(xscale) - ax0.set_xticks([]) - - # Plot the histogram manually with fill_between - interval_var = [0] + list(df.bins) - for i in range(len(df)): - count = df['count'].values[i] - ax0.fill_between([interval_var[i], interval_var[i+1]], [0] * 2, [count] * 2, - facecolor=plt.cm.Greys(0.75), alpha=1, - edgecolor='white', linewidth=0.1) - ax0.set_ylabel('Sample count') - # Scientific format to avoid undesired additional space on the label side - ax0.ticklabel_format(axis='y', style='sci', scilimits=(0, 0)) - ax0.set_xlim((0, np.max(df.bins))) - - # Now, plot the statistic of the data - ax = fig.add_subplot(grid[3:, :]) - - # Get the bins center - bins_center = np.subtract(df.bins, np.diff([0] + df.bins.tolist()) / 2) - - # If all the estimated errors are all NaN (single run), simply plot the empirical variogram - if np.all(np.isnan(df.err_exp)): - ax.scatter(bins_center, df.exp, label='Empirical variogram', color='blue', marker='x') - # Otherwise, plot the error estimates through multiple runs + init_gridsize = [10, 10] + # Create parameters to split x axis into different linear scales + # If there is no split, get parameters for a single subplot + if xscale_range_split is None: + nb_subpanels=1 + xmin = [0] + xmax = [np.max(df.bins)] + xgridmin = [0] + xgridmax = [init_gridsize[0]] + gridsize = init_gridsize + # Otherwise, derive a list for each subplot else: - ax.errorbar(bins_center, df.exp, yerr=df.err_exp, label='Empirical variogram (1-sigma s.d)', fmt='x') - - # If a list of functions is passed, plot the modelled variograms - if list_fit_fun is not None: - for i, fit_fun in enumerate(list_fit_fun): - x = np.linspace(0, np.max(df.bins), 10000) - y = fit_fun(x) - - if list_fit_fun_label is not None: - ax.plot(x, y, linestyle='dashed', label=list_fit_fun_label[i], zorder=30) - else: - ax.plot(x, y, linestyle='dashed', color='black', zorder=30) + # Add initial zero if not in input + if xscale_range_split[0] != 0: + xscale_range_split = [0] + xscale_range_split + # Add maximum distance if not in input + if xscale_range_split[-1] != np.max(df.bins): + xscale_range_split.append(np.max(df.bins)) + + # Scale grid size by the number of subpanels + nb_subpanels = len(xscale_range_split)-1 + gridsize = init_gridsize.copy() + gridsize[0] *= nb_subpanels + # Create list of parameters to pass to ax/grid objects of subpanels + xmin, xmax, xgridmin, xgridmax = ([] for i in range(4)) + for i in range(nb_subpanels): + xmin.append(xscale_range_split[i]) + xmax.append(xscale_range_split[i+1]) + xgridmin.append(init_gridsize[0]*i) + xgridmax.append(init_gridsize[0]*(i+1)) - if list_fit_fun_label is None: - ax.plot([],[],linestyle='dashed',color='black',label='Model fit') + # Need a grid plot to show the sample count and the statistic + grid = plt.GridSpec(gridsize[1], gridsize[0], wspace=0.5, hspace=0.5) + + # Loop over each subpanel + for k in range(nb_subpanels): + # First, an axis to plot the sample histogram + ax0 = fig.add_subplot(grid[:3, xgridmin[k]:xgridmax[k]]) + ax0.set_xscale(xscale) + ax0.set_xticks([]) + + # Plot the histogram manually with fill_between + interval_var = [0] + list(df.bins) + for i in range(len(df)): + count = df['count'].values[i] + ax0.fill_between([interval_var[i], interval_var[i+1]], [0] * 2, [count] * 2, + facecolor=plt.cm.Greys(0.75), alpha=1, + edgecolor='white', linewidth=0.5) + if k == 0: + ax0.set_ylabel('Sample count') + # Scientific format to avoid undesired additional space on the label side + ax0.ticklabel_format(axis='y', style='sci', scilimits=(0, 0)) + else: + ax0.set_yticks([]) + # Ignore warnings for log scales + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + ax0.set_xlim((xmin[k], xmax[k])) + + # Now, plot the statistic of the data + ax = fig.add_subplot(grid[3:, xgridmin[k]:xgridmax[k]]) + + # Get the bins center + bins_center = np.subtract(df.bins, np.diff([0] + df.bins.tolist()) / 2) + + # If all the estimated errors are all NaN (single run), simply plot the empirical variogram + if np.all(np.isnan(df.err_exp)): + ax.scatter(bins_center, df.exp, label='Empirical variogram', color='blue', marker='x') + # Otherwise, plot the error estimates through multiple runs + else: + ax.errorbar(bins_center, df.exp, yerr=df.err_exp, label='Empirical variogram (1-sigma s.d)', fmt='x') + + # If a list of functions is passed, plot the modelled variograms + if list_fit_fun is not None: + for i, fit_fun in enumerate(list_fit_fun): + x = np.linspace(0, np.max(df.bins), 10000) + y = fit_fun(x) + + if list_fit_fun_label is not None: + ax.plot(x, y, linestyle='dashed', label=list_fit_fun_label[i], zorder=30) + else: + ax.plot(x, y, linestyle='dashed', color='black', zorder=30) + + if list_fit_fun_label is None: + ax.plot([],[],linestyle='dashed',color='black',label='Model fit') + + ax.set_xscale(xscale) + if nb_subpanels>1 and k == (nb_subpanels-1): + ax.xaxis.set_ticks(np.linspace(xmin[k], xmax[k], 3)) + elif nb_subpanels>1: + ax.xaxis.set_ticks(np.linspace(xmin[k],xmax[k],3)[:-1]) + + # Ignore warning for log scales + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + ax.set_xlim((xmin[k], xmax[k])) + + if k == int(nb_subpanels/2): + ax.set_xlabel('Lag (m)') + ax.legend(loc='best') + if k == 0: + ax.set_ylabel(r'Variance [$\mu$ $\pm \sigma$]') + else: + ax.set_yticks([]) - ax.set_xlabel('Lag (m)') - ax.set_ylabel(r'Variance [$\mu$ $\pm \sigma$]') - ax.legend(loc='best') - ax.set_xscale(xscale) - ax.set_xlim((0, np.max(df.bins))) def plot_1d_binning(df: pd.DataFrame, var_name: str, statistic_name: str, label_var: Optional[str] = None, label_statistic: Optional[str] = None, min_count: int = 30, ax: matplotlib.axes.Axes | None = None): @@ -1310,7 +1379,7 @@ def plot_1d_binning(df: pd.DataFrame, var_name: str, statistic_name: str, label_ for i in range(len(df_sub) ): count = df_sub['count'].values[i] ax0.fill_between([interval_var[i].left, interval_var[i].right], [0] * 2, [count] * 2, facecolor=plt.cm.Greys(0.75), alpha=1, - edgecolor='white',linewidth=0.1) + edgecolor='white',linewidth=0.5) ax0.set_ylabel('Sample count') # Scientific format to avoid undesired additional space on the label side ax0.ticklabel_format(axis='y',style='sci',scilimits=(0,0)) @@ -1397,7 +1466,7 @@ def plot_2d_binning(df: pd.DataFrame, var_name_1: str, var_name_2: str, statisti count = np.nansum(df_var1['count'].values) list_counts.append(count) ax0.fill_between([df_var1[var_name_1].values[0].left, df_var1[var_name_1].values[0].right], [0] * 2, [count] * 2, facecolor=plt.cm.Greys(0.75), alpha=1, - edgecolor='white') + edgecolor='white', linewidth=0.5) ax0.set_ylabel('Sample count') # In case the axis value does not agree with the scale (e.g., 0 for log scale) with warnings.catch_warnings(): @@ -1427,7 +1496,7 @@ def plot_2d_binning(df: pd.DataFrame, var_name_1: str, var_name_2: str, statisti count = np.nansum(df_var2['count'].values) list_counts.append(count) ax1.fill_between([0, count], [df_var2[var_name_2].values[0].left] * 2, [df_var2[var_name_2].values[0].right] * 2, facecolor=plt.cm.Greys(0.75), - alpha=1, edgecolor='white') + alpha=1, edgecolor='white', linewidth=0.5) ax1.set_xlabel('Sample count') # In case the axis value does not agree with the scale (e.g., 0 for log scale) with warnings.catch_warnings(): From 1f7a416ce38f42a48b3729f31d22a7fdd623c647 Mon Sep 17 00:00:00 2001 From: rhugonne Date: Tue, 27 Jul 2021 20:33:32 +0200 Subject: [PATCH 073/113] finalize plot_vgm gallery example --- examples/plot_vgm_error.py | 124 +++++++++++++++++++++++++++++++------ 1 file changed, 105 insertions(+), 19 deletions(-) diff --git a/examples/plot_vgm_error.py b/examples/plot_vgm_error.py index ea804191..a5d1cd94 100644 --- a/examples/plot_vgm_error.py +++ b/examples/plot_vgm_error.py @@ -41,11 +41,21 @@ mask_glacier = glacier_outlines.create_mask(dh) # %% -# We remove values on glacier terrain +# We remove values on glacier terrain, to use only stable terrain as a proxy for the elevation measurement errors dh.data[mask_glacier] = np.nan # %% -# Let's plot the elevation differences +# We estimate the average per-pixel elevation measurement error on stable terrain, using both the standard deviation +# and normalized median absolute deviation +print('STD: {:.2f}'.format(np.nanstd(dh.data))) +print('NMAD: {:.2f}'.format(xdem.spatialstats.nmad(dh.data))) + +# %% +# The two measures are quite similar which shows that, on average, there is a limited influence of outliers on the +# elevation differences. The precision per-pixel is, on average, :math:`\pm` 2.5 meters at the 1-sigma confidence level. +# Yet, the per-pixel precision is a limited metric to quantify the quality of the data to perform further spatial +# analysis. +# Let's plot the elevation differences to visually check the quality of the data. plt.figure(figsize=(8, 5)) plt_extent = [ dh.bounds.left, @@ -60,11 +70,16 @@ # %% -# We can see that the elevation differences are still polluted by unmasked glaciers: let's filter outliers outside 4 NMAD +# We see that the residual elevation differences on stable terrain are clearly not random. The positive and negative +# differences (blue and red, respectively) seem correlated over large distances. **This needs to be quantified to +# estimate elevation measurement errors for a sum, or average of elevation difference a certain surface area**. +# Additionally, the elevation differences are still polluted by unrealistically large elevation differences near +# glaciers, probably because the glacier inventory is more recent than the data, and the outlines are too small. +# To remedy this, let's filter elevation difference outliers outside 4 NMAD. dh.data[np.abs(dh.data) > 4 * xdem.spatialstats.nmad(dh.data)] = np.nan # %% -# Let's plot the elevation differences after filtering +# We plot the elevation differences after filtering. plt.figure(figsize=(8, 5)) plt_extent = [ dh.bounds.left, @@ -78,34 +93,67 @@ plt.show() # %% -# Sample empirical variogram +# To quantify the spatial correlation of the data, we sample an empirical variogram which calculates the covariance +# between the elevation differences of pairs of pixels depending on their distance. This distance between pairs of +# pixels if referred to as spatial lag. +# To perform this effectively, we use methods providing efficient pairwise sampling methods for large grid data, +# encapsulated by :func:`xdem.spatialstats.sample_multirange_variogram`: df = xdem.spatialstats.sample_multirange_variogram( values=dh.data, gsd=dh.res[0], subsample=50, runs=30, nrun=10) -# Plot empirical variogram +# %% +# We can now plot the empirical variogram: xdem.spatialstats.plot_vgm(df) # %% -# A lot of things are happening at the +# With this plot, it is hard to conclude anything. +# Properly visualizing the empirical variogram is one of the most important step. With grid data, we expect short-range +# correlations close to the resolution of the grid (~20-200 meters), but also possibly longer range correlation due to +# instrument noise or alignment issues (~1-50 km) (Hugonnet et al., in prep). +# To better visualize the variogram, we can either change the axis to log-scale, but this might make it more difficult +# to later compare to variogram models. +# Another solution is to split the variogram plot into subpanels, each with its own linear scale: +xdem.spatialstats.plot_vgm(df, xscale='log') +xdem.spatialstats.plot_vgm(df, xscale_range_split=[100, 1000, 10000]) # %% -# +# We identify a short-range (short spatial lag) correlation, likely due to effects of resolution, which has a large +# partial sill (correlated variance), meaning that the elevation measurement errors are strongly correlated until a +# range of ~200 m. +# We also identify a longer range correlation, with a smaller partial sill, meaning the part of the elevation +# measurement errors remain correlated over a longer distance. +# To show the difference between accounting only for the most noticeable, short-range correlation, and the long-range +# correlation, we fit those empirical variogram with two different models: a single spherical model (one range), and +# the sum of two spherical models (two ranges). +# For this, we use :func:`xdem.spatialstats.fit_model_sum_vgm`: fun, params1 = xdem.spatialstats.fit_model_sum_vgm(['Sph'], emp_vgm_df=df) - fun2, params2 = xdem.spatialstats.fit_model_sum_vgm(['Sph', 'Sph'], emp_vgm_df=df) xdem.spatialstats.plot_vgm(df,list_fit_fun=[fun, fun2],list_fit_fun_label=['Single-range model', 'Double-range model'], xscale='log') +xdem.spatialstats.plot_vgm(df,list_fit_fun=[fun, fun2],list_fit_fun_label=['Single-range model', 'Double-range model'], + xscale_range_split=[100, 1000, 10000]) -# %% -# Let's see how this affect the precision of the DEM integrated over a certain surface area, from pixel size to grid size - -# Areas varying from pixel size squared to grid size squared, with same unit as the variogram parameters (meters) -areas = [400*2**i for i in range(20)] -# Derive the precision for each area -list_stderr_singlerange, list_stderr_doublerange = ([] for i in range(2)) +# %% +# **The sum of two spherical models seems to fit better, by modelling a small additional partial sill at longer ranges. +# This additional partial sill (correlated variance) is quite small, and one could thus wonder that the influence on +# the estimation of elevation measurement error will also be small.** +# However, even if the correlated variance if small, long-range correlated signals have a large effect on measurement +# errors. +# Let's show how this affect the precision of the DEM integrated over a certain surface area, from pixel size to grid +# size, by spatially integrating the variogram model using :func:`xdem.spatialstats.neff_circ`. # We validate that the +# double-range model provides more realistic estimationss of the error based on intensive Monte-Carlo sampling +# ("patches" method) over the data grid (Dehecq et al. (2020), Hugonnet et al., in prep), which +# # is integrated in :func:`xdem.spatialstats.patches_method`. + +# We store the integrated elevation measurement error for each area +list_stderr_singlerange, list_stderr_doublerange, list_stderr_empirical = ([] for i in range(3)) + +# Numerical and exact integration of variogram run fast, so we derive errors for many surface areas from squared pixel +# size to squared grid size, with same unit as the variogram (meters) +areas = np.linspace(20**2, 10000**2, 1000) for area in areas: # Number of effective samples integrated over the area for a single-range model @@ -118,17 +166,55 @@ # Convert into a standard error stderr_singlerange = np.nanstd(dh.data)/np.sqrt(neff_singlerange) stderr_doublerange = np.nanstd(dh.data)/np.sqrt(neff_doublerange) - list_stderr_singlerange.append(stderr_singlerange) list_stderr_doublerange.append(stderr_doublerange) +# Sample only feasable areas for patches method to avoid long processing times: increasing exponentially from areas of +# 5 pixels to areas of 10000 pixels +areas_emp = [10 * 400 * 2 ** i for i in range(10)] +for area_emp in areas_emp: + + # Empirically estimate standard error: + # 1/ Sample intensively circular patches of a given area, and derive the mean elevation differences + df_patches = xdem.spatialstats.patches_method(dh.data.data, gsd=dh.res[0], area=area_emp, nmax=200, verbose=True) + # 2/ Estimate the dispersion of the patches means, i.e. the standard error of the mean + stderr_empirical = np.nanstd(df_patches['mean'].values) + list_stderr_empirical.append(stderr_empirical) + fig, ax = plt.subplots() -plt.scatter(np.asarray(areas)/1000000, list_stderr_singlerange, label='Single-range spherical model') -plt.scatter(np.asarray(areas)/1000000, list_stderr_doublerange, label='Double-range spherical model') +plt.plot(np.asarray(areas)/1000000, list_stderr_singlerange, label='Single-range spherical model') +plt.plot(np.asarray(areas)/1000000, list_stderr_doublerange, label='Double-range spherical model') +plt.scatter(np.asarray(areas_emp)/1000000, list_stderr_empirical, label='Empirical estimate', color='black', marker='x') plt.xlabel('Averaging area (km²)') plt.ylabel('Uncertainty in the mean elevation difference (m)') plt.xscale('log') +plt.yscale('log') plt.legend() +# %% +# Using a single-range variogram can underestimates the integrated elevation measurement error by a factor of ~100 for +# large surface areas, be careful to multi-range variogram ! + +list_stderr_doublerange_plus_fullycorrelated = [] +for area in areas: + + # For a double-range model + neff_doublerange = xdem.spatialstats.neff_circ(area, [(params2[0], 'Sph', params2[1]), + (params2[2], 'Sph', params2[3])]) + # About 10% of the variance might be fully correlated, the other 90% has the random part that we quantified + stderr_fullycorr = np.sqrt(0.1*np.nanvar(dh.data)) + stderr_doublerange = np.sqrt(0.9*np.nanvar(dh.data))/np.sqrt(neff_doublerange) + list_stderr_doublerange_plus_fullycorrelated.append(stderr_fullycorr + stderr_doublerange) +fig, ax = plt.subplots() +plt.plot(np.asarray(areas)/1000000, list_stderr_singlerange, label='Single-range spherical model') +plt.plot(np.asarray(areas)/1000000, list_stderr_doublerange, label='Double-range spherical model') +plt.plot(np.asarray(areas)/1000000, list_stderr_doublerange_plus_fullycorrelated, + label='10% fully correlated,\n 90% double-range spherical model') +plt.scatter(np.asarray(areas_emp)/1000000, list_stderr_empirical, label='Empirical estimate', color='black', marker='x') +plt.xlabel('Averaging area (km²)') +plt.ylabel('Uncertainty in the mean elevation difference (m)') +plt.xscale('log') +plt.yscale('log') +plt.legend() From 586490f8ce860288a53a89841b85c9e1ff055a4f Mon Sep 17 00:00:00 2001 From: rhugonne Date: Wed, 28 Jul 2021 13:19:49 +0200 Subject: [PATCH 074/113] refine plot_vgm example --- examples/plot_vgm_error.py | 174 ++++++++++++++++++++++--------------- 1 file changed, 105 insertions(+), 69 deletions(-) diff --git a/examples/plot_vgm_error.py b/examples/plot_vgm_error.py index a5d1cd94..5565b261 100644 --- a/examples/plot_vgm_error.py +++ b/examples/plot_vgm_error.py @@ -4,35 +4,37 @@ Digital elevation models have elevation measurement errors that can vary with terrain or instrument-related variables (see :ref:`sphx_glr_auto_examples_plot_nonstationary_error.py`), but those measurement errors are also often -`spatially correlated `_. -While many DEM studies have been using short-range `variogram `_ models to +`correlated in space `_. +While many DEM studies have been using short-range `variogram `_ to estimate the correlation of elevation measurement errors (e.g., `Howat et al. (2008) `_ , `Wang and Kääb (2015) `_), recent studies show that variograms of multiple ranges -provide more realistic estimates of spatial correlation for many DEMs (e.g., `Dehecq et al. (2020) `_ +provide larger, more reliable estimates of spatial correlation for DEMs (e.g., `Dehecq et al. (2020) `_ , `Hugonnet et al. (2021) `_). Quantifying the spatial correlation in elevation measurement errors is essential to integrate measurement errors over an area of interest (e.g, to estimate the error of a mean or sum of samples). Once the spatial correlations are quantified, -several methods exist the approximate the measurement error in space (`Rolstad et al. (2009) `_ -, Hugonnet et al. (in prep)). Further details are availale in :ref:`spatialstats`. +several methods exist to derive the related measurement error integrated in space (`Rolstad et al. (2009) `_ +, Hugonnet et al. (in prep)). More details are available in :ref:`spatialstats`. Here, we show an example in which we estimate spatially integrated elevation measurement errors for a DEM difference of -Longyearbyen glacier. We first quantify the spatial correlations using :func:`xdem.spatialstats.sample_multirange_empirical_variogram` -based on routines of `scikit-gstat `_. We then model the empirical variogram -using a sum of variogram models using :func:`xdem.spatialstats.fit_model_sum_vgm`. +Longyearbyen glacier, demonstrated in :ref:`sphx_glr_auto_examples_plot_nuth_kaab.py`. We first quantify the spatial +correlations using :func:`xdem.spatialstats.sample_multirange_variogram` based on routines of `scikit-gstat +`_. We then model the empirical variogram using a sum of variogram +models using :func:`xdem.spatialstats.fit_model_sum_vgm`. Finally, we integrate the variogram models for varying surface areas to estimate the spatially integrated elevation -measurement errors for this DEM difference. +measurement errors using :func:`xdem.spatialstats.neff_circ`, and empirically validate the improved robustness of +our results using `xdem.spatialstats.patches_method`, an intensive Monte-Carlo sampling approach. """ -# sphinx_gallery_thumbnail_number = 5 +# sphinx_gallery_thumbnail_number = 6 import matplotlib.pyplot as plt import numpy as np import xdem import geoutils as gu # %% -# We start by loading example files including a difference of DEMs at Longyearbyen glacier, the reference DEM later used to derive -# several terrain attributes, and the outlines to rasterize a glacier mask. +# We start by loading example files including a difference of DEMs at Longyearbyen glacier and the outlines to rasterize +# a glacier mask. # Prior to differencing, the DEMs were aligned using :class:`xdem.coreg.NuthKaab` as shown in # the :ref:`sphx_glr_auto_examples_plot_nuth_kaab.py` example. We later refer to those elevation differences as *dh*. @@ -41,20 +43,20 @@ mask_glacier = glacier_outlines.create_mask(dh) # %% -# We remove values on glacier terrain, to use only stable terrain as a proxy for the elevation measurement errors +# We remove values on glacier terrain in order to isolate stable terrain, our proxy for elevation measurement errors. dh.data[mask_glacier] = np.nan # %% # We estimate the average per-pixel elevation measurement error on stable terrain, using both the standard deviation -# and normalized median absolute deviation -print('STD: {:.2f}'.format(np.nanstd(dh.data))) -print('NMAD: {:.2f}'.format(xdem.spatialstats.nmad(dh.data))) +# and normalized median absolute deviation. For this example, we do not account for the non-stationarity in elevation +# measurement errors quantified in :ref:`sphx_glr_auto_examples_plot_nonstationary_error.py`. +print('STD: {:.2f}'.format(np.nanstd(dh.data))+' meters.') +print('NMAD: {:.2f}'.format(xdem.spatialstats.nmad(dh.data))+' meters.') # %% -# The two measures are quite similar which shows that, on average, there is a limited influence of outliers on the -# elevation differences. The precision per-pixel is, on average, :math:`\pm` 2.5 meters at the 1-sigma confidence level. -# Yet, the per-pixel precision is a limited metric to quantify the quality of the data to perform further spatial -# analysis. +# The two measures of dispersion are quite similar showing that, on average, there is a small influence of outliers on the +# elevation differences. The per-pixel precision is about :math:`\pm` 2.5 meters. +# **Does this mean that every pixel has an independent measurement error of** :math:`\pm` **2.5 meters?** # Let's plot the elevation differences to visually check the quality of the data. plt.figure(figsize=(8, 5)) plt_extent = [ @@ -70,16 +72,20 @@ # %% -# We see that the residual elevation differences on stable terrain are clearly not random. The positive and negative -# differences (blue and red, respectively) seem correlated over large distances. **This needs to be quantified to -# estimate elevation measurement errors for a sum, or average of elevation difference a certain surface area**. -# Additionally, the elevation differences are still polluted by unrealistically large elevation differences near -# glaciers, probably because the glacier inventory is more recent than the data, and the outlines are too small. -# To remedy this, let's filter elevation difference outliers outside 4 NMAD. +# We clearly see that the residual elevation differences on stable terrain are not random. The positive and negative +# differences (blue and red, respectively) appear correlated over large distances. +# +# Conclusion: **These correlated errors need to be quantified to reliably estimate elevation measurement errors for a +# sum, or average of elevation differences samples for a specific surface area**. + +# %% +# Additionally, we notice that the elevation differences are still polluted by unrealistically large elevation +# differences near glaciers, probably because the glacier inventory is more recent than the data, and the outlines are too small. +# To remedy this, we filter large elevation differences outside 4 NMAD. dh.data[np.abs(dh.data) > 4 * xdem.spatialstats.nmad(dh.data)] = np.nan # %% -# We plot the elevation differences after filtering. +# We plot the elevation differences after filtering to check that we successively removed the reminaing glacier signals. plt.figure(figsize=(8, 5)) plt_extent = [ dh.bounds.left, @@ -93,16 +99,17 @@ plt.show() # %% -# To quantify the spatial correlation of the data, we sample an empirical variogram which calculates the covariance -# between the elevation differences of pairs of pixels depending on their distance. This distance between pairs of -# pixels if referred to as spatial lag. -# To perform this effectively, we use methods providing efficient pairwise sampling methods for large grid data, -# encapsulated by :func:`xdem.spatialstats.sample_multirange_variogram`: +# To quantify the spatial correlation of the data, we sample an empirical variogram. +# The empirical variogram describes the variance between the elevation differences of pairs of pixels depending on their +# distance. This distance between pairs of pixels if referred to as spatial lag. +# To perform this procedure effectively, we use improved methods that provide efficient pairwise sampling methods for +# large grid data in `scikit-gstat `_, which are encapsulated +# conveniently by :func:`xdem.spatialstats.sample_multirange_variogram`: df = xdem.spatialstats.sample_multirange_variogram( - values=dh.data, gsd=dh.res[0], subsample=50, runs=30, nrun=10) + values=dh.data, gsd=dh.res[0], subsample=50, runs=30, nrun=10, estimator='cressie') # %% -# We can now plot the empirical variogram: +# We plot the empirical variogram: xdem.spatialstats.plot_vgm(df) # %% @@ -110,50 +117,50 @@ # Properly visualizing the empirical variogram is one of the most important step. With grid data, we expect short-range # correlations close to the resolution of the grid (~20-200 meters), but also possibly longer range correlation due to # instrument noise or alignment issues (~1-50 km) (Hugonnet et al., in prep). - # To better visualize the variogram, we can either change the axis to log-scale, but this might make it more difficult # to later compare to variogram models. # Another solution is to split the variogram plot into subpanels, each with its own linear scale: + +# %% +# **Log scale:** xdem.spatialstats.plot_vgm(df, xscale='log') + +# %% +# **Subpanels with linear scale:** xdem.spatialstats.plot_vgm(df, xscale_range_split=[100, 1000, 10000]) # %% -# We identify a short-range (short spatial lag) correlation, likely due to effects of resolution, which has a large +# We identify a short-range (i.e., correlation length) correlation, likely due to effects of resolution. It has a large # partial sill (correlated variance), meaning that the elevation measurement errors are strongly correlated until a -# range of ~200 m. +# range of ~100 m. # We also identify a longer range correlation, with a smaller partial sill, meaning the part of the elevation # measurement errors remain correlated over a longer distance. -# To show the difference between accounting only for the most noticeable, short-range correlation, and the long-range -# correlation, we fit those empirical variogram with two different models: a single spherical model (one range), and +# In order to show the difference between accounting only for the most noticeable, short-range correlation, or adding the +# long-range correlation, we fit this empirical variogram with two different models: a single spherical model, and # the sum of two spherical models (two ranges). -# For this, we use :func:`xdem.spatialstats.fit_model_sum_vgm`: +# For this, we use :func:`xdem.spatialstats.fit_model_sum_vgm`, which is based on `scipy.optimize.curve_fit +# `_: fun, params1 = xdem.spatialstats.fit_model_sum_vgm(['Sph'], emp_vgm_df=df) fun2, params2 = xdem.spatialstats.fit_model_sum_vgm(['Sph', 'Sph'], emp_vgm_df=df) -xdem.spatialstats.plot_vgm(df,list_fit_fun=[fun, fun2],list_fit_fun_label=['Single-range model', 'Double-range model'], - xscale='log') + xdem.spatialstats.plot_vgm(df,list_fit_fun=[fun, fun2],list_fit_fun_label=['Single-range model', 'Double-range model'], xscale_range_split=[100, 1000, 10000]) - # %% -# **The sum of two spherical models seems to fit better, by modelling a small additional partial sill at longer ranges. -# This additional partial sill (correlated variance) is quite small, and one could thus wonder that the influence on -# the estimation of elevation measurement error will also be small.** -# However, even if the correlated variance if small, long-range correlated signals have a large effect on measurement -# errors. -# Let's show how this affect the precision of the DEM integrated over a certain surface area, from pixel size to grid -# size, by spatially integrating the variogram model using :func:`xdem.spatialstats.neff_circ`. # We validate that the -# double-range model provides more realistic estimationss of the error based on intensive Monte-Carlo sampling -# ("patches" method) over the data grid (Dehecq et al. (2020), Hugonnet et al., in prep), which -# # is integrated in :func:`xdem.spatialstats.patches_method`. - -# We store the integrated elevation measurement error for each area -list_stderr_singlerange, list_stderr_doublerange, list_stderr_empirical = ([] for i in range(3)) +# The sum of two spherical models fits better, adding a small partial sill at longer ranges. -# Numerical and exact integration of variogram run fast, so we derive errors for many surface areas from squared pixel +# %% +# **This longer range partial sill (correlated variance) is quite small, so is it really important to account for this +# small additional "bump" in the variogram?** +# We compute the precision of the DEM integrated over a certain surface area based on spatial integration of the +# variogram models using :func:`xdem.spatialstats.neff_circ`, with areas varying from pixel size to grid size. +# Numerical and exact integration of variogram is fast, so we derive errors for many surface areas from squared pixel # size to squared grid size, with same unit as the variogram (meters) + areas = np.linspace(20**2, 10000**2, 1000) + +list_stderr_singlerange, list_stderr_doublerange, list_stderr_empirical = ([] for i in range(3)) for area in areas: # Number of effective samples integrated over the area for a single-range model @@ -169,15 +176,17 @@ list_stderr_singlerange.append(stderr_singlerange) list_stderr_doublerange.append(stderr_doublerange) -# Sample only feasable areas for patches method to avoid long processing times: increasing exponentially from areas of -# 5 pixels to areas of 10000 pixels +# %% +# We also compute an empirical error based on intensive Monte-Carlo sampling ("patches" method) over the data grid +# (Dehecq et al. (2020), Hugonnet et al., in prep), which is integrated in :func:`xdem.spatialstats.patches_method`. +# Here, we sample fewer areas to avoid for the patches method to run over long processing times. We increase +# exponentially from areas of 5 pixels to areas of 10000 pixels. areas_emp = [10 * 400 * 2 ** i for i in range(10)] for area_emp in areas_emp: - # Empirically estimate standard error: - # 1/ Sample intensively circular patches of a given area, and derive the mean elevation differences - df_patches = xdem.spatialstats.patches_method(dh.data.data, gsd=dh.res[0], area=area_emp, nmax=200, verbose=True) - # 2/ Estimate the dispersion of the patches means, i.e. the standard error of the mean + # First, sample intensively circular patches of a given area, and derive the mean elevation differences + df_patches = xdem.spatialstats.patches_method(dh.data.data, gsd=dh.res[0], area=area_emp, nmax=200) + # Second, estimate the dispersion of the means of each patch, i.e. the standard error of the mean stderr_empirical = np.nanstd(df_patches['mean'].values) list_stderr_empirical.append(stderr_empirical) @@ -192,8 +201,29 @@ plt.legend() # %% -# Using a single-range variogram can underestimates the integrated elevation measurement error by a factor of ~100 for -# large surface areas, be careful to multi-range variogram ! +# We demonstrate that using a single-range variogram highly underestimates the measurement error integrated over an area, +# by over a factor of ~100 for large surface areas. Using a double-range variogram brings us closer to the empirical error. +# **But, in this case, the error is still too small. Why?** +# The small size of the sampling area against the very large range of the noise means that we might no verify the +# assumption of second-order stationarity (see :ref:`spatialstats`). We might be missing even longer range correlations +# in our analysis, due to the limits of the variogram sampling. +# In other words, a small part of the variance could be fully correlated over a large part of the grid, i.e. have a +# vertical bias. +# As a first guess for this, let's examine the difference between mean and median to gain some insight on the central +# tendency of our sample: + +diff_med_mean = np.nanmean(dh.data.data)-np.nanmedian(dh.data.data) +print('Difference mean/median: {:.3f}'.format(diff_med_mean)+' meters.') + +# %% +# If we now express it as a percentage of the dispersion: + +print('{:.1f}'.format(diff_med_mean/np.nanstd(dh.data.data)*100)+ '% of STD.') + +# %% +# There might be a significant bias of central tendency, i.e. almost fully correlated measurement error across the grid. +# If we assume that around 5% of the variance is fully correlated, and re-calculate our elevation measurement errors +# accordingly. list_stderr_doublerange_plus_fullycorrelated = [] for area in areas: @@ -202,19 +232,25 @@ neff_doublerange = xdem.spatialstats.neff_circ(area, [(params2[0], 'Sph', params2[1]), (params2[2], 'Sph', params2[3])]) - # About 10% of the variance might be fully correlated, the other 90% has the random part that we quantified - stderr_fullycorr = np.sqrt(0.1*np.nanvar(dh.data)) - stderr_doublerange = np.sqrt(0.9*np.nanvar(dh.data))/np.sqrt(neff_doublerange) + # About 5% of the variance might be fully correlated, the other 95% has the random part that we quantified + stderr_fullycorr = np.sqrt(0.05*np.nanvar(dh.data)) + stderr_doublerange = np.sqrt(0.95*np.nanvar(dh.data))/np.sqrt(neff_doublerange) list_stderr_doublerange_plus_fullycorrelated.append(stderr_fullycorr + stderr_doublerange) fig, ax = plt.subplots() plt.plot(np.asarray(areas)/1000000, list_stderr_singlerange, label='Single-range spherical model') plt.plot(np.asarray(areas)/1000000, list_stderr_doublerange, label='Double-range spherical model') plt.plot(np.asarray(areas)/1000000, list_stderr_doublerange_plus_fullycorrelated, - label='10% fully correlated,\n 90% double-range spherical model') + label='5% fully correlated,\n 95% double-range spherical model') plt.scatter(np.asarray(areas_emp)/1000000, list_stderr_empirical, label='Empirical estimate', color='black', marker='x') plt.xlabel('Averaging area (km²)') plt.ylabel('Uncertainty in the mean elevation difference (m)') plt.xscale('log') plt.yscale('log') plt.legend() + +# %% +# Our final estimation is now very close to the empirical error estimate. +# Take-home points: +# 1. Long-range correlations are very important to reliably estimate measurement errors integrated in space, even if they have a small partial sill (correlated variance)! +# 2. Ideally, the grid must only contain correlation range smaller than the grid size, to verify second-order stationarity and provide robust variogram quantification. Otherwise, be wary of small biases of central tendency! \ No newline at end of file From 36028f2f65c315108834346fb4f88e206eafcd63 Mon Sep 17 00:00:00 2001 From: rhugonne Date: Wed, 28 Jul 2021 13:21:21 +0200 Subject: [PATCH 075/113] fix patches_method test --- tests/test_spatialstats.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/test_spatialstats.py b/tests/test_spatialstats.py index 5134307c..aedbbf27 100644 --- a/tests/test_spatialstats.py +++ b/tests/test_spatialstats.py @@ -256,13 +256,12 @@ def test_patches_method(self): diff, mask = load_ref_and_diff()[1:3] - warnings.filterwarnings("error") # check the patches method runs - df_patches = xdem.spatialstats.patches_method( + df = xdem.spatialstats.patches_method( diff.data.squeeze(), mask=~mask.astype(bool).squeeze(), gsd=diff.res[0], - area_size=10000 + area=10000 ) class TestBinning: From c30dba354d36ab04dc910205d3cd45850a566150 Mon Sep 17 00:00:00 2001 From: rhugonne Date: Wed, 28 Jul 2021 16:44:56 +0200 Subject: [PATCH 076/113] update spatial stats doc page with new functions --- docs/source/code/spatialstats.py | 56 +++++++++++++++++--------------- docs/source/spatialstats.rst | 40 ++++++++++++++--------- 2 files changed, 54 insertions(+), 42 deletions(-) diff --git a/docs/source/code/spatialstats.py b/docs/source/code/spatialstats.py index 058918ba..7cc0766c 100644 --- a/docs/source/code/spatialstats.py +++ b/docs/source/code/spatialstats.py @@ -2,33 +2,35 @@ import xdem import geoutils as gu import numpy as np -# -# # Load data -# ddem = gu.georaster.Raster(xdem.examples.get_path("longyearbyen_ddem")) -# glacier_mask = gu.geovector.Vector(xdem.examples.get_path("longyearbyen_glacier_outlines")) -# mask = glacier_mask.create_mask(ddem) -# -# # Get slope for non-stationarity -# slope = xdem.coreg.calculate_slope_and_aspect(ddem.data)[0] -# -# # Keep only stable terrain data -# ddem.data[mask] = np.nan -# -# # Get non-stationarities by bins -# df_ns = xdem.spatialstats.nd_binning(ddem.data.ravel(), list_var=[slope.ravel()], list_var_names=['slope']) -# -# # Sample empirical variogram -# df_vgm = xdem.spatialstats.sample_multirange_empirical_variogram(dh=ddem.data, nsamp=1000, nrun=20, nproc=10, maxlag=10000) -# -# # Fit single-range spherical model -# fun, coefs = xdem.spatialstats.fit_model_sum_vgm(['Sph'], emp_vgm_df=df_vgm) -# -# # Fit sum of triple-range spherical model -# fun2, coefs2 = xdem.spatialstats.fit_model_sum_vgm(['Sph', 'Sph', 'Sph'], emp_vgm_df=df_vgm) -# -# # Calculate the area-averaged uncertainty with these models -# list_vgm = [(coefs[2*i],'Sph',coefs[2*i+1]) for i in range(int(len(coefs)/2))] -# neff = xdem.spatialstats.neff_circ(1, list_vgm) + +# Load data +dh = gu.georaster.Raster(xdem.examples.get_path("longyearbyen_ddem")) +glacier_mask = gu.geovector.Vector(xdem.examples.get_path("longyearbyen_glacier_outlines")) +mask = glacier_mask.create_mask(dh) + +# Get slope for non-stationarity +slope = xdem.terrain.get_terrain_attribute(dh.data, resolution=dh.res[0], attribute=['slope']) + +# Keep only stable terrain data +dh.data[mask] = np.nan + +# Estimate the measurement error by bin of slope, using the NMAD as robust estimator +df_ns = xdem.spatialstats.nd_binning(dh.data.ravel(), list_var=[slope.ravel()], list_var_names=['slope'], + statistics=['count', xdem.spatialstats.nmad]) + +# Derive a numerical function of the measurement error +err_dh = xdem.spatialstats.interp_nd_binning(df_ns, list_var_names=['slope']) + +# Sample empirical variogram +df_vgm = xdem.spatialstats.sample_multirange_variogram(values=dh.data, gsd=dh.res[0], subsample=100, runs=10, nrun=10) + +# Fit sum of triple-range spherical model +fun, coefs = xdem.spatialstats.fit_sum_variogram(list_model=['Sph', 'Sph', 'Sph'], emp_vgm_df=df_vgm) + +# Calculate the area-averaged uncertainty with these models +list_vgm = [(coefs[2*i],'Sph',coefs[2*i+1]) for i in range(3)] +area = 1000 +neff = xdem.spatialstats.neff_circ(area, list_vgm) diff --git a/docs/source/spatialstats.rst b/docs/source/spatialstats.rst index 96013b6f..ef3c075b 100644 --- a/docs/source/spatialstats.rst +++ b/docs/source/spatialstats.rst @@ -73,47 +73,57 @@ Workflow for DEM precision estimation Non-stationarity in elevation measurement errors ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -.. minigallery:: xdem.spatialstats.nd_binning - :add-heading: - Quantify and model non-stationarites """""""""""""""""""""""""""""""""""" -TODO: Add this section based on Hugonnet et al. (in prep) - .. literalinclude:: code/spatialstats.py - :lines: 16-17 + :lines: 17-19 + +.. minigallery:: xdem.spatialstats.nd_binning + :add-heading: + +TODO: Add this section based on Hugonnet et al. (in prep) Standardize elevation differences for further analysis """""""""""""""""""""""""""""""""""""""""""""""""""""" +TODO: Add a new gallery example Spatial correlation of elevation measurement errors ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ TODO: Add this section based Rolstad et al. (2009), Dehecq et al. (2020), Hugonnet et al. (in prep) -Quantify and model spatial correlations -""""""""""""""""""""""""""""""""""""""" +Quantify spatial correlations +""""""""""""""""""""""""""""" + +Estimate empirical variogram: .. literalinclude:: code/spatialstats.py - :lines: 19-20 + :lines: 24-25 -For a single range model: +.. minigallery:: xdem.spatialstats.sample_multirange_variogram -.. literalinclude:: code/spatialstats.py - :lines: 22-23 +Model spatial correlations +"""""""""""""""""""""""""" -For multiple range model: +Fit a multiple-range model: .. literalinclude:: code/spatialstats.py - :lines: 25-26 + :lines: 27-28 -.. plot:: code/spatialstats_plot_vgm.py +.. minigallery:: xdem.spatialstats.fit_sum_variogram Spatially integrated measurement errors ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +Deduce an effective sample size, and elevation measurement error: + +.. literalinclude:: code/spatialstats.py + :lines: 30-33 + +.. minigallery:: xdem.spatialstats.neff_circ + TODO: Add this section based on Rolstad et al. (2009), Hugonnet et al. (in prep) Propagation of correlated errors From ce1330d8f94b452822055ac718eb687b71fd4b25 Mon Sep 17 00:00:00 2001 From: rhugonne Date: Wed, 28 Jul 2021 17:18:15 +0200 Subject: [PATCH 077/113] improve modularity of patches_method, random_state, add more tests --- tests/test_spatialstats.py | 29 +++++++++++++++----- xdem/spatialstats.py | 55 +++++++++++++++++++++++++------------- 2 files changed, 59 insertions(+), 25 deletions(-) diff --git a/tests/test_spatialstats.py b/tests/test_spatialstats.py index aedbbf27..8459a856 100644 --- a/tests/test_spatialstats.py +++ b/tests/test_spatialstats.py @@ -136,7 +136,7 @@ def test_multirange_fit_performance(self): df = df.assign(bins=x, exp=y_simu, err_exp=sigma) # Run the fitting - fun, params_est = xdem.spatialstats.fit_model_sum_vgm(['Sph', 'Sph', 'Sph'], df) + fun, params_est = xdem.spatialstats.fit_sum_variogram(['Sph', 'Sph', 'Sph'], df) for i in range(len(params_est)): # Assert all parameters were correctly estimated within a 30% relative margin @@ -156,12 +156,12 @@ def test_empirical_fit_plotting(self): values=diff.data, gsd=diff.res[0], subsample=50, random_state=42, runs=10) # Single model fit - fun, _ = xdem.spatialstats.fit_model_sum_vgm(['Sph'], df) + fun, _ = xdem.spatialstats.fit_sum_variogram(['Sph'], df) if PLOT: xdem.spatialstats.plot_vgm(df, list_fit_fun=[fun]) # Triple model fit - fun2, _ = xdem.spatialstats.fit_model_sum_vgm(['Sph', 'Sph', 'Sph'], emp_vgm_df=df) + fun2, _ = xdem.spatialstats.fit_sum_variogram(['Sph', 'Sph', 'Sph'], emp_vgm_df=df) if PLOT: xdem.spatialstats.plot_vgm(df, list_fit_fun=[fun2]) @@ -256,14 +256,29 @@ def test_patches_method(self): diff, mask = load_ref_and_diff()[1:3] - # check the patches method runs + gsd = diff.res[0] + area = 10000 + + # Check the patches method runs df = xdem.spatialstats.patches_method( - diff.data.squeeze(), + diff.data, mask=~mask.astype(bool).squeeze(), - gsd=diff.res[0], - area=10000 + gsd=gsd, + area=area, + random_state=42, + nmax=100 ) + # Check we get the expected shape + assert df.shape == (100, 4) + + # Check the sampling is always fixed for a random state + assert df['tile'].values[0] == '31_184' + assert df['nanmedian'].values[0] == pytest.approx(2.28, abs=0.01) + + # Check that all counts respect the default minimum percentage of 80% valid pixels + assert all(df['count'].values > 0.8*np.max(df['count'].values)) + class TestBinning: def test_nd_binning(self): diff --git a/xdem/spatialstats.py b/xdem/spatialstats.py index 5f874cb8..dc314121 100644 --- a/xdem/spatialstats.py +++ b/xdem/spatialstats.py @@ -575,7 +575,7 @@ def sample_multirange_variogram(values: Union[np.ndarray,RasterType], gsd: float :param nrun: number of runs :param nproc: number of processing cores :param verbose: print statements during processing - :param random_state: random state or seed number to use for calculations (to fix drawings during testing) + :param random_state: random state or seed number to use for calculations (to fix random sampling during testing) :return: empirical variogram (variance, lags, counts) """ @@ -711,7 +711,7 @@ def sample_multirange_variogram(values: Union[np.ndarray,RasterType], gsd: float return df -def fit_model_sum_vgm(list_model: list[str], emp_vgm_df: pd.DataFrame) -> tuple[Callable, list[float]]: +def fit_sum_variogram(list_model: list[str], emp_vgm_df: pd.DataFrame) -> tuple[Callable, list[float]]: """ Fit a multi-range variogram model to an empirical variogram, weighted based on sampling and elevation errors @@ -1101,8 +1101,9 @@ def double_sum_covar(list_tuple_errs: list[float], corr_ranges: list[float], lis def patches_method(values: np.ndarray, gsd: float, area: float, mask: Optional[np.ndarray] = None, - perc_min_valid: float = 80., patch_shape: str = 'circular', nmax: int = 1000, verbose: bool = False)\ - -> pd.DataFrame: + perc_min_valid: float = 80., statistics: Iterable[Union[str, Callable, None]] = ['count', np.nanmedian ,nmad], + patch_shape: str = 'circular', nmax: int = 1000, verbose: bool = False, + random_state: None | int | np.random.RandomState | np.random.Generator = None) -> pd.DataFrame: """ Patches method for empirical estimation of the standard error over an integration area @@ -1112,14 +1113,27 @@ def patches_method(values: np.ndarray, gsd: float, area: float, mask: Optional[n :param mask: mask of sampled terrain :param area: size of integration area :param perc_min_valid: minimum valid area in the patch + :param statistics: list of statistics to compute in the patch :param patch_shape: shape of patch ['circular' or 'rectangular'] :param nmax: maximum number of patch to sample :param verbose: print statement to console + :param random_state: random state or seed number to use for calculations (to fix random sampling during testing) :return: tile, mean, median, std and count of each patch """ - # TODO: make robust to Raster inputs, masked arrays, etc... + # Define state for random subsampling (to fix results during testing) + if random_state is None: + rnd = np.random.default_rng() + elif isinstance(random_state, (np.random.RandomState, np.random.Generator)): + rnd = random_state + else: + rnd = np.random.RandomState(np.random.MT19937(np.random.SeedSequence(random_state))) + + statistics_name = [f if isinstance(f,str) else f.__name__ for f in statistics] + + values, mask_values = get_array_and_mask(values) + values = values.squeeze() # Use all grid if no mask is provided @@ -1127,7 +1141,7 @@ def patches_method(values: np.ndarray, gsd: float, area: float, mask: Optional[n mask = np.ones(np.shape(values),dtype=bool) # First, remove non sampled area (but we need to keep the 2D shape of raster for patch sampling) - valid_mask = np.logical_and(np.isfinite(values), mask) + valid_mask = np.logical_and(~mask_values, mask) values[~valid_mask] = np.nan # Divide raster in cadrants where we can sample @@ -1143,18 +1157,17 @@ def patches_method(values: np.ndarray, gsd: float, area: float, mask: Optional[n # For circular patches rad = np.sqrt(area/np.pi) / gsd - tile, mean_patch, med_patch, std_patch, nb_patch = ([] for i in range(5)) - # Create list of all possible cadrants list_cadrant = [[i, j] for i in range(nb_cadrant) for j in range(nb_cadrant)] u = 0 # Keep sampling while there is cadrants left and below maximum number of patch to sample remaining_nsamp = nmax + list_df = [] while len(list_cadrant) > 0 and u < nmax: # Draw a random coordinate from the list of cadrants, select more than enough random points to avoid drawing # randomly and differencing lists several times - list_idx_cadrant = np.random.choice(len(list_cadrant), size=min(len(list_cadrant), 10*remaining_nsamp)) + list_idx_cadrant = rnd.choice(len(list_cadrant), size=min(len(list_cadrant), 10*remaining_nsamp)) for idx_cadrant in list_idx_cadrant: @@ -1184,21 +1197,27 @@ def patches_method(values: np.ndarray, gsd: float, area: float, mask: Optional[n if verbose: print('Found valid cadrant ' + str(u)+ ' (maximum: '+str(nmax)+')') - tile.append(str(i) + '_' + str(j)) - mean_patch.append(np.nanmean(patch)) - med_patch.append(np.nanmedian(patch.filled(np.nan) if isinstance(patch, np.ma.masked_array) else patch)) - std_patch.append(np.nanstd(patch)) - nb_patch.append(nb_pixel_valid) + df = pd.DataFrame() + df = df.assign(tile=[str(i) + '_' + str(j)]) + for j, statistic in enumerate(statistics): + if isinstance(statistic, str): + if statistic == 'count': + df[statistic] = [nb_pixel_valid] + else: + raise ValueError('No other string than "count" are supported for named statistics.') + else: + df[statistics_name[j]] = [statistic(patch)] + + list_df.append(df) # Get remaining samples to draw remaining_nsamp = nmax - u # Remove cadrants already sampled from list list_cadrant = [c for j, c in enumerate(list_cadrant) if j not in list_idx_cadrant] - df = pd.DataFrame() - df = df.assign(tile=tile, mean=mean_patch, med=med_patch, std=std_patch, count=nb_patch) + df_all = pd.concat(list_df) - return df + return df_all def plot_vgm(df: pd.DataFrame, list_fit_fun: Optional[list[Callable[[float],float]]] = None, @@ -1207,7 +1226,7 @@ def plot_vgm(df: pd.DataFrame, list_fit_fun: Optional[list[Callable[[float],floa """ Plot empirical variogram, and optionally also plot one or several model fits. Input dataframe is expected to be the output of xdem.spatialstats.sample_multirange_variogram. - Input function model is expected to be the output of xdem.spatialstats.fit_model_sum_vgm. + Input function model is expected to be the output of xdem.spatialstats.fit_sum_variogram. :param df: dataframe of empirical variogram :param list_fit_fun: list of model function fits From afa57138d16b372dec262335b226bb8cb0fbf0fe Mon Sep 17 00:00:00 2001 From: rhugonne Date: Wed, 28 Jul 2021 17:32:39 +0200 Subject: [PATCH 078/113] fixes with amaurys comments --- examples/plot_vgm_error.py | 41 ++++++++++---------------------------- 1 file changed, 11 insertions(+), 30 deletions(-) diff --git a/examples/plot_vgm_error.py b/examples/plot_vgm_error.py index 5565b261..b88921f4 100644 --- a/examples/plot_vgm_error.py +++ b/examples/plot_vgm_error.py @@ -20,7 +20,7 @@ Longyearbyen glacier, demonstrated in :ref:`sphx_glr_auto_examples_plot_nuth_kaab.py`. We first quantify the spatial correlations using :func:`xdem.spatialstats.sample_multirange_variogram` based on routines of `scikit-gstat `_. We then model the empirical variogram using a sum of variogram -models using :func:`xdem.spatialstats.fit_model_sum_vgm`. +models using :func:`xdem.spatialstats.fit_sum_variogram`. Finally, we integrate the variogram models for varying surface areas to estimate the spatially integrated elevation measurement errors using :func:`xdem.spatialstats.neff_circ`, and empirically validate the improved robustness of our results using `xdem.spatialstats.patches_method`, an intensive Monte-Carlo sampling approach. @@ -50,8 +50,8 @@ # We estimate the average per-pixel elevation measurement error on stable terrain, using both the standard deviation # and normalized median absolute deviation. For this example, we do not account for the non-stationarity in elevation # measurement errors quantified in :ref:`sphx_glr_auto_examples_plot_nonstationary_error.py`. -print('STD: {:.2f}'.format(np.nanstd(dh.data))+' meters.') -print('NMAD: {:.2f}'.format(xdem.spatialstats.nmad(dh.data))+' meters.') +print('STD: {:.2f} meters.'.format(np.nanstd(dh.data))) +print('NMAD: {:.2f} meters.'.format(xdem.spatialstats.nmad(dh.data))) # %% # The two measures of dispersion are quite similar showing that, on average, there is a small influence of outliers on the @@ -59,17 +59,7 @@ # **Does this mean that every pixel has an independent measurement error of** :math:`\pm` **2.5 meters?** # Let's plot the elevation differences to visually check the quality of the data. plt.figure(figsize=(8, 5)) -plt_extent = [ - dh.bounds.left, - dh.bounds.right, - dh.bounds.bottom, - dh.bounds.top, -] -plt.imshow(dh.data.squeeze(), cmap="RdYlBu", vmin=-4, vmax=4, extent=plt_extent) -cbar = plt.colorbar() -cbar.set_label('Elevation differences (m)') -plt.show() - +dh.show(ax=plt.gca(), cmap='RdYlBu', vmin=-4, vmax=4, cb_title='Elevation differences (m)') # %% # We clearly see that the residual elevation differences on stable terrain are not random. The positive and negative @@ -87,16 +77,7 @@ # %% # We plot the elevation differences after filtering to check that we successively removed the reminaing glacier signals. plt.figure(figsize=(8, 5)) -plt_extent = [ - dh.bounds.left, - dh.bounds.right, - dh.bounds.bottom, - dh.bounds.top, -] -plt.imshow(dh.data.squeeze(), cmap="RdYlBu", vmin=-4, vmax=4, extent=plt_extent) -cbar = plt.colorbar() -cbar.set_label('Elevation differences (m)') -plt.show() +dh.show(ax=plt.gca(), cmap='RdYlBu', vmin=-4, vmax=4, cb_title='Elevation differences (m)') # %% # To quantify the spatial correlation of the data, we sample an empirical variogram. @@ -138,11 +119,11 @@ # In order to show the difference between accounting only for the most noticeable, short-range correlation, or adding the # long-range correlation, we fit this empirical variogram with two different models: a single spherical model, and # the sum of two spherical models (two ranges). -# For this, we use :func:`xdem.spatialstats.fit_model_sum_vgm`, which is based on `scipy.optimize.curve_fit +# For this, we use :func:`xdem.spatialstats.fit_sum_variogram`, which is based on `scipy.optimize.curve_fit # `_: -fun, params1 = xdem.spatialstats.fit_model_sum_vgm(['Sph'], emp_vgm_df=df) +fun, params1 = xdem.spatialstats.fit_sum_variogram(['Sph'], emp_vgm_df=df) -fun2, params2 = xdem.spatialstats.fit_model_sum_vgm(['Sph', 'Sph'], emp_vgm_df=df) +fun2, params2 = xdem.spatialstats.fit_sum_variogram(['Sph', 'Sph'], emp_vgm_df=df) xdem.spatialstats.plot_vgm(df,list_fit_fun=[fun, fun2],list_fit_fun_label=['Single-range model', 'Double-range model'], xscale_range_split=[100, 1000, 10000]) @@ -187,7 +168,7 @@ # First, sample intensively circular patches of a given area, and derive the mean elevation differences df_patches = xdem.spatialstats.patches_method(dh.data.data, gsd=dh.res[0], area=area_emp, nmax=200) # Second, estimate the dispersion of the means of each patch, i.e. the standard error of the mean - stderr_empirical = np.nanstd(df_patches['mean'].values) + stderr_empirical = np.nanstd(df_patches['nanmedian'].values) list_stderr_empirical.append(stderr_empirical) fig, ax = plt.subplots() @@ -213,12 +194,12 @@ # tendency of our sample: diff_med_mean = np.nanmean(dh.data.data)-np.nanmedian(dh.data.data) -print('Difference mean/median: {:.3f}'.format(diff_med_mean)+' meters.') +print('Difference mean/median: {:.3f} meters.'.format(diff_med_mean)) # %% # If we now express it as a percentage of the dispersion: -print('{:.1f}'.format(diff_med_mean/np.nanstd(dh.data.data)*100)+ '% of STD.') +print('{:.1f} % of STD.'.format(diff_med_mean/np.nanstd(dh.data.data)*100)) # %% # There might be a significant bias of central tendency, i.e. almost fully correlated measurement error across the grid. From 78abe22df2ca7976652dea4caed3516c792708ec Mon Sep 17 00:00:00 2001 From: rhugonne Date: Wed, 28 Jul 2021 17:52:57 +0200 Subject: [PATCH 079/113] fix histogram bug with log scale for plot_vgm --- xdem/spatialstats.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/xdem/spatialstats.py b/xdem/spatialstats.py index dc314121..189d25a2 100644 --- a/xdem/spatialstats.py +++ b/xdem/spatialstats.py @@ -1251,7 +1251,7 @@ def plot_vgm(df: pd.DataFrame, list_fit_fun: Optional[list[Callable[[float],floa # If there is no split, get parameters for a single subplot if xscale_range_split is None: nb_subpanels=1 - xmin = [0] + xmin = [np.min(df.bins)/10] xmax = [np.max(df.bins)] xgridmin = [0] xgridmax = [init_gridsize[0]] @@ -1260,7 +1260,7 @@ def plot_vgm(df: pd.DataFrame, list_fit_fun: Optional[list[Callable[[float],floa else: # Add initial zero if not in input if xscale_range_split[0] != 0: - xscale_range_split = [0] + xscale_range_split + xscale_range_split = [np.min(df.bins)/10] + xscale_range_split # Add maximum distance if not in input if xscale_range_split[-1] != np.max(df.bins): xscale_range_split.append(np.max(df.bins)) @@ -1301,9 +1301,7 @@ def plot_vgm(df: pd.DataFrame, list_fit_fun: Optional[list[Callable[[float],floa else: ax0.set_yticks([]) # Ignore warnings for log scales - with warnings.catch_warnings(): - warnings.simplefilter("ignore") - ax0.set_xlim((xmin[k], xmax[k])) + ax0.set_xlim((xmin[k], xmax[k])) # Now, plot the statistic of the data ax = fig.add_subplot(grid[3:, xgridmin[k]:xgridmax[k]]) @@ -1338,10 +1336,7 @@ def plot_vgm(df: pd.DataFrame, list_fit_fun: Optional[list[Callable[[float],floa elif nb_subpanels>1: ax.xaxis.set_ticks(np.linspace(xmin[k],xmax[k],3)[:-1]) - # Ignore warning for log scales - with warnings.catch_warnings(): - warnings.simplefilter("ignore") - ax.set_xlim((xmin[k], xmax[k])) + ax.set_xlim((xmin[k], xmax[k])) if k == int(nb_subpanels/2): ax.set_xlabel('Lag (m)') From b5704bfcf800e50b23a7747f52b3f32ecbd6bbb6 Mon Sep 17 00:00:00 2001 From: rhugonne Date: Wed, 28 Jul 2021 17:53:12 +0200 Subject: [PATCH 080/113] polish text --- examples/plot_vgm_error.py | 59 +++++++++++++++++++------------------- 1 file changed, 29 insertions(+), 30 deletions(-) diff --git a/examples/plot_vgm_error.py b/examples/plot_vgm_error.py index b88921f4..9ccd3844 100644 --- a/examples/plot_vgm_error.py +++ b/examples/plot_vgm_error.py @@ -63,10 +63,8 @@ # %% # We clearly see that the residual elevation differences on stable terrain are not random. The positive and negative -# differences (blue and red, respectively) appear correlated over large distances. -# -# Conclusion: **These correlated errors need to be quantified to reliably estimate elevation measurement errors for a -# sum, or average of elevation differences samples for a specific surface area**. +# differences (blue and red, respectively) appear correlated over large distances. These correlated errors are what +# we aim to quantify. # %% # Additionally, we notice that the elevation differences are still polluted by unrealistically large elevation @@ -94,13 +92,13 @@ xdem.spatialstats.plot_vgm(df) # %% -# With this plot, it is hard to conclude anything. -# Properly visualizing the empirical variogram is one of the most important step. With grid data, we expect short-range -# correlations close to the resolution of the grid (~20-200 meters), but also possibly longer range correlation due to -# instrument noise or alignment issues (~1-50 km) (Hugonnet et al., in prep). +# With this plot, it is hard to conclude anything! Properly visualizing the empirical variogram is one of the most +# important step. With grid data, we expect short-range correlations close to the resolution of the grid (~20-200 +# meters), but also possibly longer range correlation due to instrument noise or alignment issues (~1-50 km) (Hugonnet et al., in prep). +# # To better visualize the variogram, we can either change the axis to log-scale, but this might make it more difficult -# to later compare to variogram models. -# Another solution is to split the variogram plot into subpanels, each with its own linear scale: +# to later compare to variogram models. # Another solution is to split the variogram plot into subpanels, each with +# its own linear scale. Both are shown below. # %% # **Log scale:** @@ -111,16 +109,17 @@ xdem.spatialstats.plot_vgm(df, xscale_range_split=[100, 1000, 10000]) # %% -# We identify a short-range (i.e., correlation length) correlation, likely due to effects of resolution. It has a large +# We identify: +# - a short-range (i.e., correlation length) correlation, likely due to effects of resolution. It has a large # partial sill (correlated variance), meaning that the elevation measurement errors are strongly correlated until a # range of ~100 m. -# We also identify a longer range correlation, with a smaller partial sill, meaning the part of the elevation -# measurement errors remain correlated over a longer distance. +# - a longer range correlation, with a smaller partial sill, meaning the part of the elevation measurement errors +# remain correlated over a longer distance. +# # In order to show the difference between accounting only for the most noticeable, short-range correlation, or adding the # long-range correlation, we fit this empirical variogram with two different models: a single spherical model, and -# the sum of two spherical models (two ranges). -# For this, we use :func:`xdem.spatialstats.fit_sum_variogram`, which is based on `scipy.optimize.curve_fit -# `_: +# the sum of two spherical models (two ranges). For this, we use :func:`xdem.spatialstats.fit_sum_variogram`, which +# is based on `scipy.optimize.curve_fit `_: fun, params1 = xdem.spatialstats.fit_sum_variogram(['Sph'], emp_vgm_df=df) fun2, params2 = xdem.spatialstats.fit_sum_variogram(['Sph', 'Sph'], emp_vgm_df=df) @@ -129,15 +128,14 @@ xscale_range_split=[100, 1000, 10000]) # %% -# The sum of two spherical models fits better, adding a small partial sill at longer ranges. +# The sum of two spherical models fits better, accouting for the small partial sill at longer ranges. # %% # **This longer range partial sill (correlated variance) is quite small, so is it really important to account for this # small additional "bump" in the variogram?** # We compute the precision of the DEM integrated over a certain surface area based on spatial integration of the # variogram models using :func:`xdem.spatialstats.neff_circ`, with areas varying from pixel size to grid size. -# Numerical and exact integration of variogram is fast, so we derive errors for many surface areas from squared pixel -# size to squared grid size, with same unit as the variogram (meters) +# Numerical and exact integration of variogram is fast, allowing us to estimate errors for a wide range of areas radidly. areas = np.linspace(20**2, 10000**2, 1000) @@ -158,10 +156,10 @@ list_stderr_doublerange.append(stderr_doublerange) # %% -# We also compute an empirical error based on intensive Monte-Carlo sampling ("patches" method) over the data grid -# (Dehecq et al. (2020), Hugonnet et al., in prep), which is integrated in :func:`xdem.spatialstats.patches_method`. -# Here, we sample fewer areas to avoid for the patches method to run over long processing times. We increase -# exponentially from areas of 5 pixels to areas of 10000 pixels. +# We add an empirical error based on intensive Monte-Carlo sampling ("patches" method) to validate our results +# (Dehecq et al. (2020), Hugonnet et al., in prep). This method is implemented in :func:`xdem.spatialstats.patches_method`. +# Here, we sample fewer areas to avoid for the patches method to run over long processing times, increasing from areas +# of 5 pixels to areas of 10000 pixels exponentially. areas_emp = [10 * 400 * 2 ** i for i in range(10)] for area_emp in areas_emp: @@ -182,14 +180,15 @@ plt.legend() # %% -# We demonstrate that using a single-range variogram highly underestimates the measurement error integrated over an area, -# by over a factor of ~100 for large surface areas. Using a double-range variogram brings us closer to the empirical error. +# Using a single-range variogram highly underestimates the measurement error integrated over an area, by over a factor +# of ~100 for large surface areas. Using a double-range variogram brings us closer to the empirical error. +# # **But, in this case, the error is still too small. Why?** # The small size of the sampling area against the very large range of the noise means that we might no verify the # assumption of second-order stationarity (see :ref:`spatialstats`). We might be missing even longer range correlations -# in our analysis, due to the limits of the variogram sampling. -# In other words, a small part of the variance could be fully correlated over a large part of the grid, i.e. have a -# vertical bias. +# in our analysis, due to the limits of the variogram sampling. In other words, a small part of the variance could be +# fully correlated over a large part of the grid, i.e. have a vertical bias. +# # As a first guess for this, let's examine the difference between mean and median to gain some insight on the central # tendency of our sample: @@ -233,5 +232,5 @@ # %% # Our final estimation is now very close to the empirical error estimate. # Take-home points: -# 1. Long-range correlations are very important to reliably estimate measurement errors integrated in space, even if they have a small partial sill (correlated variance)! -# 2. Ideally, the grid must only contain correlation range smaller than the grid size, to verify second-order stationarity and provide robust variogram quantification. Otherwise, be wary of small biases of central tendency! \ No newline at end of file +# 1. Long-range correlations are very important to reliably estimate measurement errors integrated in space, even if they have a small partial sill (correlated variance)! +# 2. Ideally, the grid must only contain correlation range smaller than the grid size, to verify second-order stationarity and provide robust variogram quantification. Otherwise, be wary of small biases of central tendency! \ No newline at end of file From 14725e52bbc75ec2c715f3b03873d50b8cf9d427 Mon Sep 17 00:00:00 2001 From: rhugonne Date: Wed, 28 Jul 2021 17:55:59 +0200 Subject: [PATCH 081/113] refine plot_vgm xmin --- xdem/spatialstats.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/xdem/spatialstats.py b/xdem/spatialstats.py index 189d25a2..91688d3c 100644 --- a/xdem/spatialstats.py +++ b/xdem/spatialstats.py @@ -1251,7 +1251,10 @@ def plot_vgm(df: pd.DataFrame, list_fit_fun: Optional[list[Callable[[float],floa # If there is no split, get parameters for a single subplot if xscale_range_split is None: nb_subpanels=1 - xmin = [np.min(df.bins)/10] + if xscale == 'log': + xmin = [np.min(df.bins)/2] + else: + xmin = [0] xmax = [np.max(df.bins)] xgridmin = [0] xgridmax = [init_gridsize[0]] @@ -1260,7 +1263,11 @@ def plot_vgm(df: pd.DataFrame, list_fit_fun: Optional[list[Callable[[float],floa else: # Add initial zero if not in input if xscale_range_split[0] != 0: - xscale_range_split = [np.min(df.bins)/10] + xscale_range_split + if xscale == 'log': + first_xmin = np.min(df.bins)/2 + else: + first_xmin = 0 + xscale_range_split = [first_xmin] + xscale_range_split # Add maximum distance if not in input if xscale_range_split[-1] != np.max(df.bins): xscale_range_split.append(np.max(df.bins)) From d65b55b7dc9c04e14c046b07b542bd6698f44463 Mon Sep 17 00:00:00 2001 From: rhugonne Date: Thu, 29 Jul 2021 15:00:27 +0200 Subject: [PATCH 082/113] refactor nruns into n_variograms and nproc into n_jobs (like scikit) --- docs/source/code/spatialstats.py | 3 ++- tests/test_spatialstats.py | 2 +- xdem/spatialstats.py | 26 +++++++++++++------------- 3 files changed, 16 insertions(+), 15 deletions(-) diff --git a/docs/source/code/spatialstats.py b/docs/source/code/spatialstats.py index 7cc0766c..97071b90 100644 --- a/docs/source/code/spatialstats.py +++ b/docs/source/code/spatialstats.py @@ -22,7 +22,8 @@ err_dh = xdem.spatialstats.interp_nd_binning(df_ns, list_var_names=['slope']) # Sample empirical variogram -df_vgm = xdem.spatialstats.sample_multirange_variogram(values=dh.data, gsd=dh.res[0], subsample=100, runs=10, nrun=10) +df_vgm = xdem.spatialstats.sample_multirange_variogram(values=dh.data, gsd=dh.res[0], subsample=100, runs=10, + n_variograms=10) # Fit sum of triple-range spherical model fun, coefs = xdem.spatialstats.fit_sum_variogram(list_model=['Sph', 'Sph', 'Sph'], emp_vgm_df=df_vgm) diff --git a/tests/test_spatialstats.py b/tests/test_spatialstats.py index 8459a856..2c4f62e2 100644 --- a/tests/test_spatialstats.py +++ b/tests/test_spatialstats.py @@ -50,7 +50,7 @@ def test_sample_multirange_variogram_default(self): # Test multiple runs df2 = xdem.spatialstats.sample_multirange_variogram( values=diff.data, gsd=diff.res[0], subsample=50, - random_state=42, runs=2, nrun=2) + random_state=42, runs=2, n_variograms=2) # Check that an error is estimated assert any(~np.isnan(df2.err_exp.values)) diff --git a/xdem/spatialstats.py b/xdem/spatialstats.py index 91688d3c..df437e21 100644 --- a/xdem/spatialstats.py +++ b/xdem/spatialstats.py @@ -533,8 +533,8 @@ def _wrapper_get_empirical_variogram(argdict: dict) -> pd.DataFrame: def sample_multirange_variogram(values: Union[np.ndarray,RasterType], gsd: float = None, coords: np.ndarray = None, - subsample: int = 10000, subsample_method: str = 'cdist_equidistant', nrun: int = 1, - nproc: int = 1, verbose=False, + subsample: int = 10000, subsample_method: str = 'cdist_equidistant', + n_variograms: int = 1, n_jobs: int = 1, verbose=False, random_state: None | np.random.RandomState | np.random.Generator | int = None, **kwargs) -> pd.DataFrame: """ @@ -572,8 +572,8 @@ def sample_multirange_variogram(values: Union[np.ndarray,RasterType], gsd: float :param coords: coordinates :param subsample: number of samples to randomly draw from the values :param subsample_method: spatial subsampling method - :param nrun: number of runs - :param nproc: number of processing cores + :param n_variograms: number of independent empirical variogram estimations + :param n_jobs: number of processing cores :param verbose: print statements during processing :param random_state: random state or seed number to use for calculations (to fix random sampling during testing) @@ -604,7 +604,7 @@ def sample_multirange_variogram(values: Union[np.ndarray,RasterType], gsd: float '"pdist_disk" or "pdist_ring".') # Check that, for several runs, the binning function is an Iterable, otherwise skgstat might provide variogram # values over slightly different binnings due to randomly changing subsample maximum lags - if nrun > 1 and 'bin_func' in kwargs.keys() and not isinstance(kwargs.get('bin_func'), Iterable): + if n_variograms > 1 and 'bin_func' in kwargs.keys() and not isinstance(kwargs.get('bin_func'), Iterable): warnings.warn('Using a named binning function of scikit-gstat might provide different binnings for each ' 'independent run. To remediate that issue, pass bin_func as an Iterable of right bin edges, ' '(or use default bin_func).') @@ -670,24 +670,24 @@ def sample_multirange_variogram(values: Union[np.ndarray,RasterType], gsd: float # Derive the variogram # Differentiate between 1 core and several cores for multiple runs - # All runs have random sampling inherent to their subfunctions, so we provide the same input arguments - if nproc == 1: + # All variogram runs have random sampling inherent to their subfunctions, so we provide the same input arguments + if n_jobs == 1: if verbose: print('Using 1 core...') list_df_run = [] - for i in range(nrun): + for i in range(n_variograms): - argdict = {'i': i, 'imax': nrun, **args, **kwargs} + argdict = {'i': i, 'imax': n_variograms, **args, **kwargs} df_run = _wrapper_get_empirical_variogram(argdict=argdict) list_df_run.append(df_run) else: if verbose: - print('Using ' + str(nproc) + ' cores...') + print('Using ' + str(n_jobs) + ' cores...') - pool = mp.Pool(nproc, maxtasksperchild=1) - argdict = [{'i': i, 'imax': nrun, **args, **kwargs} for i in range(nrun)] + pool = mp.Pool(n_jobs, maxtasksperchild=1) + argdict = [{'i': i, 'imax': n_variograms, **args, **kwargs} for i in range(n_variograms)] list_df_run = pool.map(_wrapper_get_empirical_variogram, argdict, chunksize=1) pool.close() pool.join() @@ -696,7 +696,7 @@ def sample_multirange_variogram(values: Union[np.ndarray,RasterType], gsd: float df = pd.concat(list_df_run) # For a single run, no multi-run sigma estimated - if nrun == 1: + if n_variograms == 1: df['err_exp'] = np.nan # For several runs, group results, use mean as empirical variogram, estimate sigma, and sum the counts else: From 2150e201ef40aa507572f0df008f85741e620f07 Mon Sep 17 00:00:00 2001 From: rhugonne Date: Thu, 29 Jul 2021 15:01:23 +0200 Subject: [PATCH 083/113] fix gallery example display --- docs/source/conf.py | 2 +- docs/source/intro.rst | 11 +++++---- examples/plot_vgm_error.py | 47 +++++++++++++++++++------------------- 3 files changed, 32 insertions(+), 28 deletions(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index 1a186ee2..2d0cfb06 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -70,7 +70,7 @@ }, # directory where function/class granular galleries are stored "backreferences_dir" : "gen_modules/backreferences", - "doc_module": ("xdem", "geoutils") # I honestly don't know what this is. + "doc_module": ("xdem", "geoutils") # which function/class levels are used to create galleries } # Add any paths that contain templates here, relative to this directory. diff --git a/docs/source/intro.rst b/docs/source/intro.rst index 35973a61..9e6de4e4 100644 --- a/docs/source/intro.rst +++ b/docs/source/intro.rst @@ -55,8 +55,9 @@ Quality-controlled DEMs aligned on high-accuracy data also exists, such as TanDE Those biases can be corrected using the methods described in :ref:`coregistration`. -.. minigallery:: xdem.coreg.NuthKaab - :add-heading: +.. minigallery:: xdem.coreg + :add-heading: + :heading-level: * Optimizing DEM relative accuracy -------------------------------- @@ -92,5 +93,7 @@ statistical measures, to allow accurate DEM uncertainty estimation for everyone. The tools for quantifying DEM precision are described in :ref:`spatialstats`. -.. minigallery:: xdem.spatialstats.nd_binning - :add-heading: \ No newline at end of file +.. minigallery:: xdem.spatialstats + :add-heading: + :heading-level: * + diff --git a/examples/plot_vgm_error.py b/examples/plot_vgm_error.py index 9ccd3844..18ba19f2 100644 --- a/examples/plot_vgm_error.py +++ b/examples/plot_vgm_error.py @@ -23,7 +23,7 @@ models using :func:`xdem.spatialstats.fit_sum_variogram`. Finally, we integrate the variogram models for varying surface areas to estimate the spatially integrated elevation measurement errors using :func:`xdem.spatialstats.neff_circ`, and empirically validate the improved robustness of -our results using `xdem.spatialstats.patches_method`, an intensive Monte-Carlo sampling approach. +our results using :func:`xdem.spatialstats.patches_method`, an intensive Monte-Carlo sampling approach. """ # sphinx_gallery_thumbnail_number = 6 @@ -35,7 +35,7 @@ # %% # We start by loading example files including a difference of DEMs at Longyearbyen glacier and the outlines to rasterize # a glacier mask. -# Prior to differencing, the DEMs were aligned using :class:`xdem.coreg.NuthKaab` as shown in +# Prior to differencing, the DEMs were aligned using as shown in # the :ref:`sphx_glr_auto_examples_plot_nuth_kaab.py` example. We later refer to those elevation differences as *dh*. dh = xdem.DEM(xdem.examples.get_path("longyearbyen_ddem")) @@ -59,7 +59,7 @@ # **Does this mean that every pixel has an independent measurement error of** :math:`\pm` **2.5 meters?** # Let's plot the elevation differences to visually check the quality of the data. plt.figure(figsize=(8, 5)) -dh.show(ax=plt.gca(), cmap='RdYlBu', vmin=-4, vmax=4, cb_title='Elevation differences (m)') +_ = dh.show(ax=plt.gca(), cmap='RdYlBu', vmin=-4, vmax=4, cb_title='Elevation differences (m)') # %% # We clearly see that the residual elevation differences on stable terrain are not random. The positive and negative @@ -75,17 +75,18 @@ # %% # We plot the elevation differences after filtering to check that we successively removed the reminaing glacier signals. plt.figure(figsize=(8, 5)) -dh.show(ax=plt.gca(), cmap='RdYlBu', vmin=-4, vmax=4, cb_title='Elevation differences (m)') +_ = dh.show(ax=plt.gca(), cmap='RdYlBu', vmin=-4, vmax=4, cb_title='Elevation differences (m)') # %% # To quantify the spatial correlation of the data, we sample an empirical variogram. # The empirical variogram describes the variance between the elevation differences of pairs of pixels depending on their # distance. This distance between pairs of pixels if referred to as spatial lag. +# # To perform this procedure effectively, we use improved methods that provide efficient pairwise sampling methods for # large grid data in `scikit-gstat `_, which are encapsulated # conveniently by :func:`xdem.spatialstats.sample_multirange_variogram`: df = xdem.spatialstats.sample_multirange_variogram( - values=dh.data, gsd=dh.res[0], subsample=50, runs=30, nrun=10, estimator='cressie') + values=dh.data, gsd=dh.res[0], subsample=50, runs=30, n_variograms=10, estimator='cressie') # %% # We plot the empirical variogram: @@ -110,11 +111,8 @@ # %% # We identify: -# - a short-range (i.e., correlation length) correlation, likely due to effects of resolution. It has a large -# partial sill (correlated variance), meaning that the elevation measurement errors are strongly correlated until a -# range of ~100 m. -# - a longer range correlation, with a smaller partial sill, meaning the part of the elevation measurement errors -# remain correlated over a longer distance. +# - a short-range (i.e., correlation length) correlation, likely due to effects of resolution. It has a large partial sill (correlated variance), meaning that the elevation measurement errors are strongly correlated until a range of ~100 m. +# - a longer range correlation, with a smaller partial sill, meaning the part of the elevation measurement errors remain correlated over a longer distance. # # In order to show the difference between accounting only for the most noticeable, short-range correlation, or adding the # long-range correlation, we fit this empirical variogram with two different models: a single spherical model, and @@ -128,12 +126,12 @@ xscale_range_split=[100, 1000, 10000]) # %% -# The sum of two spherical models fits better, accouting for the small partial sill at longer ranges. - -# %% -# **This longer range partial sill (correlated variance) is quite small, so is it really important to account for this -# small additional "bump" in the variogram?** -# We compute the precision of the DEM integrated over a certain surface area based on spatial integration of the +# The sum of two spherical models fits better, accouting for the small partial sill at longer ranges. Yet this longer +# range partial sill (correlated variance) is quite small... +# +# **So one could ask himself: is it really important to account for this small additional "bump" in the variogram?** +# +# To answer this, we compute the precision of the DEM integrated over a certain surface area based on spatial integration of the # variogram models using :func:`xdem.spatialstats.neff_circ`, with areas varying from pixel size to grid size. # Numerical and exact integration of variogram is fast, allowing us to estimate errors for a wide range of areas radidly. @@ -178,16 +176,17 @@ plt.xscale('log') plt.yscale('log') plt.legend() +plt.show() # %% # Using a single-range variogram highly underestimates the measurement error integrated over an area, by over a factor # of ~100 for large surface areas. Using a double-range variogram brings us closer to the empirical error. # # **But, in this case, the error is still too small. Why?** -# The small size of the sampling area against the very large range of the noise means that we might no verify the -# assumption of second-order stationarity (see :ref:`spatialstats`). We might be missing even longer range correlations -# in our analysis, due to the limits of the variogram sampling. In other words, a small part of the variance could be -# fully correlated over a large part of the grid, i.e. have a vertical bias. +# The small size of the sampling area against the very large range of the noise implies that we might not verify the +# assumption of second-order stationarity (see :ref:`spatialstats`). Longer range correlations might be omitted by +# our analysis, due to the limits of the variogram sampling. In other words, a small part of the variance could be +# fully correlated over a large part of the grid: a vertical bias. # # As a first guess for this, let's examine the difference between mean and median to gain some insight on the central # tendency of our sample: @@ -228,9 +227,11 @@ plt.xscale('log') plt.yscale('log') plt.legend() +plt.show() # %% # Our final estimation is now very close to the empirical error estimate. -# Take-home points: -# 1. Long-range correlations are very important to reliably estimate measurement errors integrated in space, even if they have a small partial sill (correlated variance)! -# 2. Ideally, the grid must only contain correlation range smaller than the grid size, to verify second-order stationarity and provide robust variogram quantification. Otherwise, be wary of small biases of central tendency! \ No newline at end of file +# +# Some take-home points: +# 1. Long-range correlations are very important to reliably estimate measurement errors integrated in space, even if they have a small partial sill i.e. correlated variance, +# 2. Ideally, the grid must only contain correlation patterns significantly smaller than the grid size to verify second-order stationarity. Otherwise, be wary of small biases of central tendency, i.e. fully correlated measurement errors! \ No newline at end of file From 39d0c33dd23bcffd2d422506fee41c0e1b68a693 Mon Sep 17 00:00:00 2001 From: Erik Mannerfelt <33550973+erikmannerfelt@users.noreply.github.com> Date: Thu, 29 Jul 2021 16:34:55 +0200 Subject: [PATCH 084/113] Fix spatialstats code with pytest. --- docs/source/code/spatialstats.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/source/code/spatialstats.py b/docs/source/code/spatialstats.py index 97071b90..2153efb4 100644 --- a/docs/source/code/spatialstats.py +++ b/docs/source/code/spatialstats.py @@ -24,12 +24,13 @@ # Sample empirical variogram df_vgm = xdem.spatialstats.sample_multirange_variogram(values=dh.data, gsd=dh.res[0], subsample=100, runs=10, n_variograms=10) - # Fit sum of triple-range spherical model fun, coefs = xdem.spatialstats.fit_sum_variogram(list_model=['Sph', 'Sph', 'Sph'], emp_vgm_df=df_vgm) # Calculate the area-averaged uncertainty with these models -list_vgm = [(coefs[2*i],'Sph',coefs[2*i+1]) for i in range(3)] +list_vgm = [] +for i in range(3): + list_vgm.append((coefs[2 * i], "Sph", coefs[2 * i + 1])) area = 1000 neff = xdem.spatialstats.neff_circ(area, list_vgm) From b20f024ddbd371239c2354f1f39aa618aea210de Mon Sep 17 00:00:00 2001 From: rhugonne Date: Thu, 29 Jul 2021 17:10:19 +0200 Subject: [PATCH 085/113] add random state, polish fit_sum_variogram --- docs/source/code/spatialstats.py | 10 +++--- examples/plot_vgm_error.py | 8 ++--- tests/test_spatialstats.py | 2 +- xdem/spatialstats.py | 58 +++++++++++++++++--------------- 4 files changed, 41 insertions(+), 37 deletions(-) diff --git a/docs/source/code/spatialstats.py b/docs/source/code/spatialstats.py index 2153efb4..8f2b7712 100644 --- a/docs/source/code/spatialstats.py +++ b/docs/source/code/spatialstats.py @@ -22,14 +22,14 @@ err_dh = xdem.spatialstats.interp_nd_binning(df_ns, list_var_names=['slope']) # Sample empirical variogram -df_vgm = xdem.spatialstats.sample_multirange_variogram(values=dh.data, gsd=dh.res[0], subsample=100, runs=10, - n_variograms=10) -# Fit sum of triple-range spherical model -fun, coefs = xdem.spatialstats.fit_sum_variogram(list_model=['Sph', 'Sph', 'Sph'], emp_vgm_df=df_vgm) +df_vgm = xdem.spatialstats.sample_multirange_variogram(values=dh.data, gsd=dh.res[0], subsample=50, + random_state=42, runs=10) +# Fit sum of double-range spherical model +fun, coefs = xdem.spatialstats.fit_sum_variogram(list_model=['Sph', 'Sph'], empirical_variogram=df_vgm) # Calculate the area-averaged uncertainty with these models list_vgm = [] -for i in range(3): +for i in range(2): list_vgm.append((coefs[2 * i], "Sph", coefs[2 * i + 1])) area = 1000 neff = xdem.spatialstats.neff_circ(area, list_vgm) diff --git a/examples/plot_vgm_error.py b/examples/plot_vgm_error.py index 18ba19f2..211409c1 100644 --- a/examples/plot_vgm_error.py +++ b/examples/plot_vgm_error.py @@ -86,7 +86,7 @@ # large grid data in `scikit-gstat `_, which are encapsulated # conveniently by :func:`xdem.spatialstats.sample_multirange_variogram`: df = xdem.spatialstats.sample_multirange_variogram( - values=dh.data, gsd=dh.res[0], subsample=50, runs=30, n_variograms=10, estimator='cressie') + values=dh.data, gsd=dh.res[0], subsample=50, runs=30, n_variograms=10, estimator='cressie', random_state=42) # %% # We plot the empirical variogram: @@ -118,9 +118,9 @@ # long-range correlation, we fit this empirical variogram with two different models: a single spherical model, and # the sum of two spherical models (two ranges). For this, we use :func:`xdem.spatialstats.fit_sum_variogram`, which # is based on `scipy.optimize.curve_fit `_: -fun, params1 = xdem.spatialstats.fit_sum_variogram(['Sph'], emp_vgm_df=df) +fun, params1 = xdem.spatialstats.fit_sum_variogram(['Sph'], empirical_variogram=df) -fun2, params2 = xdem.spatialstats.fit_sum_variogram(['Sph', 'Sph'], emp_vgm_df=df) +fun2, params2 = xdem.spatialstats.fit_sum_variogram(['Sph', 'Sph'], empirical_variogram=df) xdem.spatialstats.plot_vgm(df,list_fit_fun=[fun, fun2],list_fit_fun_label=['Single-range model', 'Double-range model'], xscale_range_split=[100, 1000, 10000]) @@ -162,7 +162,7 @@ for area_emp in areas_emp: # First, sample intensively circular patches of a given area, and derive the mean elevation differences - df_patches = xdem.spatialstats.patches_method(dh.data.data, gsd=dh.res[0], area=area_emp, nmax=200) + df_patches = xdem.spatialstats.patches_method(dh.data.data, gsd=dh.res[0], area=area_emp, nmax=200, random_state=42) # Second, estimate the dispersion of the means of each patch, i.e. the standard error of the mean stderr_empirical = np.nanstd(df_patches['nanmedian'].values) list_stderr_empirical.append(stderr_empirical) diff --git a/tests/test_spatialstats.py b/tests/test_spatialstats.py index 2c4f62e2..eda3638e 100644 --- a/tests/test_spatialstats.py +++ b/tests/test_spatialstats.py @@ -161,7 +161,7 @@ def test_empirical_fit_plotting(self): xdem.spatialstats.plot_vgm(df, list_fit_fun=[fun]) # Triple model fit - fun2, _ = xdem.spatialstats.fit_sum_variogram(['Sph', 'Sph', 'Sph'], emp_vgm_df=df) + fun2, _ = xdem.spatialstats.fit_sum_variogram(['Sph', 'Sph', 'Sph'], empirical_variogram=df) if PLOT: xdem.spatialstats.plot_vgm(df, list_fit_fun=[fun2]) diff --git a/xdem/spatialstats.py b/xdem/spatialstats.py index df437e21..3121dd12 100644 --- a/xdem/spatialstats.py +++ b/xdem/spatialstats.py @@ -711,16 +711,18 @@ def sample_multirange_variogram(values: Union[np.ndarray,RasterType], gsd: float return df -def fit_sum_variogram(list_model: list[str], emp_vgm_df: pd.DataFrame) -> tuple[Callable, list[float]]: +def fit_sum_variogram(list_model: list[str], empirical_variogram: pd.DataFrame) -> tuple[Callable, list[float]]: """ - Fit a multi-range variogram model to an empirical variogram, weighted based on sampling and elevation errors + Fit a multi-range variogram model to an empirical variogram, weighted least-squares based on sampling errors :param list_model: list of variogram models to sum for the fit: from short-range to long-ranges - :param emp_vgm_df: empirical variogram + :param empirical_variogram: empirical variogram :return: modelled variogram function, coefficients """ # TODO: expand to other models than spherical, exponential, gaussian (more than 2 arguments) + + # Define a sum of variogram function def vgm_sum(h, *args): fn = 0 i = 0 @@ -731,34 +733,35 @@ def vgm_sum(h, *args): return fn - # use shape of empirical variogram to assess rough boundaries/first estimates - n_average = np.ceil(len(emp_vgm_df.exp.values) / 10) - exp_movaverage = np.convolve(emp_vgm_df.exp.values, np.ones(int(n_average))/n_average, mode='valid') + # First, filter outliers + empirical_variogram = empirical_variogram[np.isfinite(empirical_variogram.exp.values)] + + # Use shape of empirical variogram to assess rough boundaries/first estimates + n_average = np.ceil(len(empirical_variogram.exp.values) / 10) + exp_movaverage = np.convolve(empirical_variogram.exp.values, np.ones(int(n_average)) / n_average, mode='valid') grad = np.gradient(exp_movaverage, 2) - # maximum variance + # Maximum variance of the process max_var = np.max(exp_movaverage) - # to simplify things for scipy, let's provide boundaries and first guesses + # Simplify things for scipy: let's provide boundaries and first guesses p0 = [] bounds = [] for i in range(len(list_model)): - # use largest boundaries possible for our problem + # Use largest boundaries possible for our problem psill_bound = [0, max_var] - range_bound = [0, emp_vgm_df.bins.values[-1]] + range_bound = [0, empirical_variogram.bins.values[-1]] - # use psill evenly distributed + # Use psill evenly distributed psill_p0 = ((i+1)/len(list_model))*max_var - # use corresponding ranges - # this fails when no empirical value crosses this (too wide binning/nugget) + # Use corresponding ranges + # !! This fails when no empirical value crosses this (too wide binning/nugget) # ind = np.array(np.abs(exp_movaverage-psill_p0)).argmin() - # range_p0 = emp_vgm_df.bins.values[ind] - range_p0 = ((i+1)/len(list_model))*emp_vgm_df.bins.values[-1] + # range_p0 = empirical_variogram.bins.values[ind] + range_p0 = ((i+1)/len(list_model)) * empirical_variogram.bins.values[-1] - # TODO: if adding other variogram models, add condition here - - # add bounds and guesses with same order as function arguments + # Add bounds and guesses with same order as function arguments bounds.append(range_bound) bounds.append(psill_bound) @@ -767,22 +770,23 @@ def vgm_sum(h, *args): bounds = np.transpose(np.array(bounds)) - if np.all(np.isnan(emp_vgm_df.err_exp.values)): - valid = ~np.isnan(emp_vgm_df.exp.values) - cof, cov = curve_fit(vgm_sum, emp_vgm_df.bins.values[valid], - emp_vgm_df.exp.values[valid], method='trf', p0=p0, bounds=bounds) + # If the error provided is all NaNs (single variogram run), or all zeros (two variogram runs), run without weights + if np.all(np.isnan(empirical_variogram.err_exp.values)) or np.all(empirical_variogram.err_exp.values == 0): + cof, cov = curve_fit(vgm_sum, empirical_variogram.bins.values, empirical_variogram.exp.values, method='trf', + p0=p0, bounds=bounds) + # Otherwise, use a weighted fit else: - valid = np.logical_and(~np.isnan(emp_vgm_df.exp.values), ~np.isnan(emp_vgm_df.err_exp.values)) - cof, cov = curve_fit(vgm_sum, emp_vgm_df.bins.values[valid], emp_vgm_df.exp.values[valid], - method='trf', p0=p0, bounds=bounds, sigma=emp_vgm_df.err_exp.values[valid]) + # We need to filter for possible no data in the error + valid = np.isfinite(empirical_variogram.err_exp.values) + cof, cov = curve_fit(vgm_sum, empirical_variogram.bins.values[valid], empirical_variogram.exp.values[valid], + method='trf', p0=p0, bounds=bounds, sigma=empirical_variogram.err_exp.values[valid]) - # rewriting the output function: couldn't find a way to pass this with functool.partial because arguments are unordered + # Provide the output function (couldn't find a way to pass this through functool.partial as arguments are unordered) def vgm_sum_fit(h): fn = 0 i = 0 for model in list_model: fn += skg.models.spherical(h, cof[i], cof[i+1]) - # fn += vgm(h, model=model,crange=args[i],psill=args[i+1]) i += 2 return fn From 41819303adfc73b907f4957b6763cebe68d10c97 Mon Sep 17 00:00:00 2001 From: rhugonne Date: Thu, 29 Jul 2021 19:24:28 +0200 Subject: [PATCH 086/113] pass down child random states to get independent variogram runs with fixed parent random state --- xdem/spatialstats.py | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/xdem/spatialstats.py b/xdem/spatialstats.py index 3121dd12..7efb9af9 100644 --- a/xdem/spatialstats.py +++ b/xdem/spatialstats.py @@ -658,7 +658,7 @@ def sample_multirange_variogram(values: Union[np.ndarray,RasterType], gsd: float # Prepare necessary arguments to pass to variogram subfunctions args = {'values': values, 'coords': coords, 'subsample_method': subsample_method, 'subsample': subsample, - 'verbose': verbose, 'random_state': random_state} + 'verbose': verbose} if subsample_method in ['cdist_equidistant','pdist_ring','pdist_disk', 'pdist_point']: # The shape is needed for those three methods args.update({'shape': (nx, ny)}) @@ -668,6 +668,25 @@ def sample_multirange_variogram(values: Union[np.ndarray,RasterType], gsd: float else: args.update({'gsd': gsd}) + # If a random_state is passed, each run needs to be passed an independent child random state, otherwise they will + # provide exactly the same sampling and results + if random_state is not None: + # Define the random state if only a seed is provided + if isinstance(random_state, (np.random.RandomState, np.random.Generator)): + rnd = random_state + else: + rnd = np.random.RandomState(np.random.MT19937(np.random.SeedSequence(random_state))) + + # Create a list of child random states + if n_variograms == 1: + # No issue if there is only one variogram run + list_random_state = [rnd] + else: + # Otherwise, pass a list of seeds + list_random_state = list(rnd.choice(n_variograms, n_variograms, replace=False)) + else: + list_random_state = [None for i in range(n_variograms)] + # Derive the variogram # Differentiate between 1 core and several cores for multiple runs # All variogram runs have random sampling inherent to their subfunctions, so we provide the same input arguments @@ -678,7 +697,7 @@ def sample_multirange_variogram(values: Union[np.ndarray,RasterType], gsd: float list_df_run = [] for i in range(n_variograms): - argdict = {'i': i, 'imax': n_variograms, **args, **kwargs} + argdict = {'i': i, 'imax': n_variograms, 'random_state': list_random_state[i], **args, **kwargs} df_run = _wrapper_get_empirical_variogram(argdict=argdict) list_df_run.append(df_run) @@ -687,7 +706,7 @@ def sample_multirange_variogram(values: Union[np.ndarray,RasterType], gsd: float print('Using ' + str(n_jobs) + ' cores...') pool = mp.Pool(n_jobs, maxtasksperchild=1) - argdict = [{'i': i, 'imax': n_variograms, **args, **kwargs} for i in range(n_variograms)] + argdict = [{'i': i, 'imax': n_variograms, 'random_state': list_random_state[i], **args, **kwargs} for i in range(n_variograms)] list_df_run = pool.map(_wrapper_get_empirical_variogram, argdict, chunksize=1) pool.close() pool.join() From 5f284f73cc31ef371b6b560e27da10cb8374eed0 Mon Sep 17 00:00:00 2001 From: rhugonne Date: Thu, 29 Jul 2021 19:24:54 +0200 Subject: [PATCH 087/113] change nuth and kaab reference to documentation to avoid sphinx gallery referencing --- examples/plot_nonstationary_error.py | 2 +- examples/plot_vgm_error.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/plot_nonstationary_error.py b/examples/plot_nonstationary_error.py index 3b735642..f8038b0c 100644 --- a/examples/plot_nonstationary_error.py +++ b/examples/plot_nonstationary_error.py @@ -28,7 +28,7 @@ # %% # We start by loading example files including a difference of DEMs at Longyearbyen glacier, the reference DEM later used to derive # several terrain attributes, and the outlines to rasterize a glacier mask. -# Prior to differencing, the DEMs were aligned using :class:`xdem.coreg.NuthKaab` as shown in +# Prior to differencing, the DEMs were aligned using :ref:`coregistration_nuthkaab` as shown in # the :ref:`sphx_glr_auto_examples_plot_nuth_kaab.py` example. We later refer to those elevation differences as *dh*. ref_dem = xdem.DEM(xdem.examples.get_path("longyearbyen_ref_dem")) diff --git a/examples/plot_vgm_error.py b/examples/plot_vgm_error.py index 211409c1..0ff21807 100644 --- a/examples/plot_vgm_error.py +++ b/examples/plot_vgm_error.py @@ -35,7 +35,7 @@ # %% # We start by loading example files including a difference of DEMs at Longyearbyen glacier and the outlines to rasterize # a glacier mask. -# Prior to differencing, the DEMs were aligned using as shown in +# Prior to differencing, the DEMs were aligned using :ref:`coregistration_nuthkaab` as shown in # the :ref:`sphx_glr_auto_examples_plot_nuth_kaab.py` example. We later refer to those elevation differences as *dh*. dh = xdem.DEM(xdem.examples.get_path("longyearbyen_ddem")) From 84e218f25bb88e9b1fc8c881e5d01cbc42fb4713 Mon Sep 17 00:00:00 2001 From: rhugonne Date: Thu, 29 Jul 2021 21:51:06 +0200 Subject: [PATCH 088/113] streamline gallery examples --- docs/source/intro.rst | 12 +++++------- docs/source/spatialstats.rst | 18 ++++++++---------- examples/plot_vgm_error.py | 13 ++++++++++++- 3 files changed, 25 insertions(+), 18 deletions(-) diff --git a/docs/source/intro.rst b/docs/source/intro.rst index 9e6de4e4..72c00ff0 100644 --- a/docs/source/intro.rst +++ b/docs/source/intro.rst @@ -55,9 +55,8 @@ Quality-controlled DEMs aligned on high-accuracy data also exists, such as TanDE Those biases can be corrected using the methods described in :ref:`coregistration`. -.. minigallery:: xdem.coreg - :add-heading: - :heading-level: * +.. minigallery:: xdem.coreg.Coreg + :add-heading: Examples that use coregistration functions Optimizing DEM relative accuracy -------------------------------- @@ -73,7 +72,7 @@ higher-accuracy point elevation data), one can identify and correct other types Those biases can be tackled by iteratively combining co-registration and bias-correction methods described in :ref:`coregistration` and :ref:`biascorr`. -TODO: Add a plot on co-registration + bias correction between two DEMs +TODO: add mini-gallery for bias correction methods Quantifying DEM precision ------------------------- @@ -93,7 +92,6 @@ statistical measures, to allow accurate DEM uncertainty estimation for everyone. The tools for quantifying DEM precision are described in :ref:`spatialstats`. -.. minigallery:: xdem.spatialstats - :add-heading: - :heading-level: * +.. minigallery:: xdem.spatialstats.sample_multirange_variogram xdem.spatialstats.nd_binning + :add-heading: Examples that use spatial statistics functions diff --git a/docs/source/spatialstats.rst b/docs/source/spatialstats.rst index ef3c075b..7a31f922 100644 --- a/docs/source/spatialstats.rst +++ b/docs/source/spatialstats.rst @@ -3,7 +3,8 @@ Spatial statistics ================== -Spatial statistics, also referred to as `geostatistics `_, are essential for the analysis of observations distributed in space. +Spatial statistics, also referred to as `geostatistics `_, are essential +for the analysis of observations distributed in space. To analyze DEMs, ``xdem`` integrates spatial statistics tools specific to DEMs described in recent literature, in particular in `Rolstad et al. (2009) `_, `Dehecq et al. (2020) `_ and @@ -73,15 +74,15 @@ Workflow for DEM precision estimation Non-stationarity in elevation measurement errors ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +.. minigallery:: xdem.spatialstats.nd_binning + :add-heading: Example that deal with non-stationarities + Quantify and model non-stationarites """""""""""""""""""""""""""""""""""" .. literalinclude:: code/spatialstats.py :lines: 17-19 -.. minigallery:: xdem.spatialstats.nd_binning - :add-heading: - TODO: Add this section based on Hugonnet et al. (in prep) Standardize elevation differences for further analysis @@ -94,6 +95,9 @@ Spatial correlation of elevation measurement errors TODO: Add this section based Rolstad et al. (2009), Dehecq et al. (2020), Hugonnet et al. (in prep) +.. minigallery:: xdem.spatialstats.sample_multirange_variogram + :add-heading: Examples that deal with spatial correlations + Quantify spatial correlations """"""""""""""""""""""""""""" @@ -102,8 +106,6 @@ Estimate empirical variogram: .. literalinclude:: code/spatialstats.py :lines: 24-25 -.. minigallery:: xdem.spatialstats.sample_multirange_variogram - Model spatial correlations """""""""""""""""""""""""" @@ -112,8 +114,6 @@ Fit a multiple-range model: .. literalinclude:: code/spatialstats.py :lines: 27-28 -.. minigallery:: xdem.spatialstats.fit_sum_variogram - Spatially integrated measurement errors ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -122,8 +122,6 @@ Deduce an effective sample size, and elevation measurement error: .. literalinclude:: code/spatialstats.py :lines: 30-33 -.. minigallery:: xdem.spatialstats.neff_circ - TODO: Add this section based on Rolstad et al. (2009), Hugonnet et al. (in prep) Propagation of correlated errors diff --git a/examples/plot_vgm_error.py b/examples/plot_vgm_error.py index 0ff21807..42300d70 100644 --- a/examples/plot_vgm_error.py +++ b/examples/plot_vgm_error.py @@ -85,9 +85,15 @@ # To perform this procedure effectively, we use improved methods that provide efficient pairwise sampling methods for # large grid data in `scikit-gstat `_, which are encapsulated # conveniently by :func:`xdem.spatialstats.sample_multirange_variogram`: + df = xdem.spatialstats.sample_multirange_variogram( values=dh.data, gsd=dh.res[0], subsample=50, runs=30, n_variograms=10, estimator='cressie', random_state=42) +# %% +# *Note: in this example, we add a* ``random_state`` *argument to yield a reproducible random sampling of pixels within +# the grid, and a* ``runs`` *argument to reduce the computing time of* ``sgstat.MetricSpace.RasterEquidistantMetricSpace`` +# *which, by default, samples more data for robustness.* + # %% # We plot the empirical variogram: xdem.spatialstats.plot_vgm(df) @@ -158,11 +164,12 @@ # (Dehecq et al. (2020), Hugonnet et al., in prep). This method is implemented in :func:`xdem.spatialstats.patches_method`. # Here, we sample fewer areas to avoid for the patches method to run over long processing times, increasing from areas # of 5 pixels to areas of 10000 pixels exponentially. + areas_emp = [10 * 400 * 2 ** i for i in range(10)] for area_emp in areas_emp: # First, sample intensively circular patches of a given area, and derive the mean elevation differences - df_patches = xdem.spatialstats.patches_method(dh.data.data, gsd=dh.res[0], area=area_emp, nmax=200, random_state=42) + df_patches = xdem.spatialstats.patches_method(dh.data.data, gsd=dh.res[0], area=area_emp, n_patches=200, random_state=42) # Second, estimate the dispersion of the means of each patch, i.e. the standard error of the mean stderr_empirical = np.nanstd(df_patches['nanmedian'].values) list_stderr_empirical.append(stderr_empirical) @@ -178,6 +185,10 @@ plt.legend() plt.show() +# %% +# *Note: in this example, we add a* ``random_state`` *argument to the patches method to yield a reproducible random +# sampling, and set* ``n_patches`` *to limit computing time.* + # %% # Using a single-range variogram highly underestimates the measurement error integrated over an area, by over a factor # of ~100 for large surface areas. Using a double-range variogram brings us closer to the empirical error. From ad1960e6bfe3a498478dac65249b1c1c064b38e5 Mon Sep 17 00:00:00 2001 From: rhugonne Date: Thu, 29 Jul 2021 21:51:28 +0200 Subject: [PATCH 089/113] refactor nmax argument of patches method into n_patches --- tests/test_spatialstats.py | 2 +- xdem/spatialstats.py | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/test_spatialstats.py b/tests/test_spatialstats.py index eda3638e..e05c8961 100644 --- a/tests/test_spatialstats.py +++ b/tests/test_spatialstats.py @@ -266,7 +266,7 @@ def test_patches_method(self): gsd=gsd, area=area, random_state=42, - nmax=100 + n_patches=100 ) # Check we get the expected shape diff --git a/xdem/spatialstats.py b/xdem/spatialstats.py index 7efb9af9..a7bad710 100644 --- a/xdem/spatialstats.py +++ b/xdem/spatialstats.py @@ -1125,7 +1125,7 @@ def double_sum_covar(list_tuple_errs: list[float], corr_ranges: list[float], lis def patches_method(values: np.ndarray, gsd: float, area: float, mask: Optional[np.ndarray] = None, perc_min_valid: float = 80., statistics: Iterable[Union[str, Callable, None]] = ['count', np.nanmedian ,nmad], - patch_shape: str = 'circular', nmax: int = 1000, verbose: bool = False, + patch_shape: str = 'circular', n_patches: int = 1000, verbose: bool = False, random_state: None | int | np.random.RandomState | np.random.Generator = None) -> pd.DataFrame: """ @@ -1138,7 +1138,7 @@ def patches_method(values: np.ndarray, gsd: float, area: float, mask: Optional[n :param perc_min_valid: minimum valid area in the patch :param statistics: list of statistics to compute in the patch :param patch_shape: shape of patch ['circular' or 'rectangular'] - :param nmax: maximum number of patch to sample + :param n_patches: maximum number of patches to sample :param verbose: print statement to console :param random_state: random state or seed number to use for calculations (to fix random sampling during testing) @@ -1184,9 +1184,9 @@ def patches_method(values: np.ndarray, gsd: float, area: float, mask: Optional[n list_cadrant = [[i, j] for i in range(nb_cadrant) for j in range(nb_cadrant)] u = 0 # Keep sampling while there is cadrants left and below maximum number of patch to sample - remaining_nsamp = nmax + remaining_nsamp = n_patches list_df = [] - while len(list_cadrant) > 0 and u < nmax: + while len(list_cadrant) > 0 and u < n_patches: # Draw a random coordinate from the list of cadrants, select more than enough random points to avoid drawing # randomly and differencing lists several times @@ -1215,10 +1215,10 @@ def patches_method(values: np.ndarray, gsd: float, area: float, mask: Optional[n nb_pixel_valid = len(patch[np.isfinite(patch)]) if nb_pixel_valid > np.ceil(perc_min_valid / 100. * nb_pixel_total): u=u+1 - if u > nmax: + if u > n_patches: break if verbose: - print('Found valid cadrant ' + str(u)+ ' (maximum: '+str(nmax)+')') + print('Found valid cadrant ' + str(u) + ' (maximum: ' + str(n_patches) + ')') df = pd.DataFrame() df = df.assign(tile=[str(i) + '_' + str(j)]) @@ -1234,7 +1234,7 @@ def patches_method(values: np.ndarray, gsd: float, area: float, mask: Optional[n list_df.append(df) # Get remaining samples to draw - remaining_nsamp = nmax - u + remaining_nsamp = n_patches - u # Remove cadrants already sampled from list list_cadrant = [c for j, c in enumerate(list_cadrant) if j not in list_idx_cadrant] From db6407efbc66613a7c8e963724dbaddb9bd07d67 Mon Sep 17 00:00:00 2001 From: rhugonne Date: Thu, 29 Jul 2021 22:40:50 +0200 Subject: [PATCH 090/113] incremental commit on documentation --- docs/source/spatialstats.rst | 48 ++++++++++++++++++++++++++++++------ 1 file changed, 40 insertions(+), 8 deletions(-) diff --git a/docs/source/spatialstats.rst b/docs/source/spatialstats.rst index 7a31f922..01a4c2ee 100644 --- a/docs/source/spatialstats.rst +++ b/docs/source/spatialstats.rst @@ -60,36 +60,65 @@ If the other elevation data is known to be of higher-precision, one can assume t .. math:: \sigma_{dh} = \sigma_{h_{\textrm{higher precision}} - h_{\textrm{lower precision}}} \approx \sigma_{h_{\textrm{lower precision}}} - -TODO: complete with Hugonnet et al. (in prep) - Using stable terrain as a proxy ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -When comparing elevation datasets, stable terrain is usually used a proxy +Stable terrain is the terrain that has supposedly not been subject to any elevation change. For example bare-rock, or +generally almost all terrain excluding glaciers, snow and forests. + +Due to the sparsity of synchronous acquisitions, elevation data cannot be easily compared over similar periods. Thus, +when comparing elevation data, stable terrain is used a proxy to assess the precision of a DEM on all its terrain, +including moving terrain that is generally of greater interest for analysis. + +As shown in Hugonnet et al. (in prep), accounting for :ref:`spatialstats_nonstationarity` is needed to reliably +use stable terrain as a proxy for other types of terrain. Workflow for DEM precision estimation ------------------------------------- +.. _spatialstats_nonstationarity: + Non-stationarity in elevation measurement errors ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -.. minigallery:: xdem.spatialstats.nd_binning - :add-heading: Example that deal with non-stationarities - Quantify and model non-stationarites """""""""""""""""""""""""""""""""""" +Non-stationarities in elevation measurement errors correspond to a variability of the precision in the elevation +observations with certain factors, that can be terrain- or instrument-related. +In statistical terms, it corresponds to an `heteroscedasticity `_ +of elevation observations. + +TODO: Add equation + +Owing to the large number of samples of elevation data, we can easily estimate this variability by `binning * +`_ the data along explanatory variables: + .. literalinclude:: code/spatialstats.py :lines: 17-19 -TODO: Add this section based on Hugonnet et al. (in prep) +Most typically, the explanatory variables used are: + - the terrain slope and terrain curvature (see :ref:`terrain_attributes) that can explain a large part of the +terrain-related variability in measurement error, + - the quality of stereo-correlation that can explain a large part of the measurement error of DEMs generated by +stereophotogrammetry, + - the interferometric coherence that can explain a large part of the measurement error of DEMs generated by +`InSAR `_. Standardize elevation differences for further analysis """""""""""""""""""""""""""""""""""""""""""""""""""""" +TODO: Add text + +TODO: Add equation + TODO: Add a new gallery example +.. minigallery:: xdem.spatialstats.nd_binning + :add-heading: Example that deal with non-stationarities + +.. _spatialstats_spatialcorr: + Spatial correlation of elevation measurement errors ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -98,6 +127,7 @@ TODO: Add this section based Rolstad et al. (2009), Dehecq et al. (2020), Hugonn .. minigallery:: xdem.spatialstats.sample_multirange_variogram :add-heading: Examples that deal with spatial correlations + Quantify spatial correlations """"""""""""""""""""""""""""" @@ -114,6 +144,8 @@ Fit a multiple-range model: .. literalinclude:: code/spatialstats.py :lines: 27-28 +.. _spatialstats_errorpropag: + Spatially integrated measurement errors ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ From 2100d154c5746b8689e66e1903ae04b06f03c489 Mon Sep 17 00:00:00 2001 From: rhugonne Date: Fri, 30 Jul 2021 01:10:47 +0200 Subject: [PATCH 091/113] incremental commit on documentation --- docs/source/spatialstats.rst | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/docs/source/spatialstats.rst b/docs/source/spatialstats.rst index 01a4c2ee..16f09d43 100644 --- a/docs/source/spatialstats.rst +++ b/docs/source/spatialstats.rst @@ -22,6 +22,8 @@ In particular, these tools help to: .. contents:: Contents :local: +.. _spatialstats_intro: + Spatial statistics for DEM precision estimation ----------------------------------------------- @@ -85,19 +87,22 @@ Quantify and model non-stationarites """""""""""""""""""""""""""""""""""" Non-stationarities in elevation measurement errors correspond to a variability of the precision in the elevation -observations with certain factors, that can be terrain- or instrument-related. +observations with certain explanatory variables that can be terrain- or instrument-related. In statistical terms, it corresponds to an `heteroscedasticity `_ of elevation observations. -TODO: Add equation +.. math:: + \sigma_{dh} = \sigma_{dh}(\textrm{var}_{1],\textrm{var}_{2}, ...) + Owing to the large number of samples of elevation data, we can easily estimate this variability by `binning * -`_ the data along explanatory variables: +`_ the data and estimating the statistical dispersion for these +explanatory variables (see :ref:`robuststats_meanstd`): .. literalinclude:: code/spatialstats.py :lines: 17-19 -Most typically, the explanatory variables used are: +The significant explanatory variables typically are: - the terrain slope and terrain curvature (see :ref:`terrain_attributes) that can explain a large part of the terrain-related variability in measurement error, - the quality of stereo-correlation that can explain a large part of the measurement error of DEMs generated by @@ -108,16 +113,21 @@ stereophotogrammetry, Standardize elevation differences for further analysis """""""""""""""""""""""""""""""""""""""""""""""""""""" -TODO: Add text +In order to verify the assumptions of spatial statistics and be able to use stable terrain as a reliable proxy in +further analysis (see :ref:`spatialstats_intro`), standardization of the elevation differences are required to +reach a stationary variance. + +.. math:: + z_{dh} = \frac{dh(\textrm{var}_{1}, \textrm{var}_{2}, ...)}{\sigma_{dh}(\textrm{var}_{1}, \textrm{var}_{2}, ...)} -TODO: Add equation +To de-standardize estimations for a given subsample :math:`\mathbb{S}g`, possibly after further analysis of :ref:`spatialstats_corr` and :ref:`spatialstats_errorpropag`, TODO: Add a new gallery example .. minigallery:: xdem.spatialstats.nd_binning :add-heading: Example that deal with non-stationarities -.. _spatialstats_spatialcorr: +.. _spatialstats_corr: Spatial correlation of elevation measurement errors ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ From e5d8889f74b4a02046e9fe7c8167dc7a47a21b25 Mon Sep 17 00:00:00 2001 From: rhugonne Date: Fri, 30 Jul 2021 01:27:59 +0200 Subject: [PATCH 092/113] incremental commit on documentation --- docs/source/spatialstats.rst | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/docs/source/spatialstats.rst b/docs/source/spatialstats.rst index 16f09d43..f36ef873 100644 --- a/docs/source/spatialstats.rst +++ b/docs/source/spatialstats.rst @@ -120,7 +120,25 @@ reach a stationary variance. .. math:: z_{dh} = \frac{dh(\textrm{var}_{1}, \textrm{var}_{2}, ...)}{\sigma_{dh}(\textrm{var}_{1}, \textrm{var}_{2}, ...)} -To de-standardize estimations for a given subsample :math:`\mathbb{S}g`, possibly after further analysis of :ref:`spatialstats_corr` and :ref:`spatialstats_errorpropag`, +To de-standardize later estimations of the dispersion of a given subsample of elevation differences, +possibly after further analysis of :ref:`spatialstats_corr` and :ref:`spatialstats_errorpropag`, +one simply needs to apply the opposite operation. + +For a single pixel :math:`\mathbb{P}`, the dispersion is directly the elevation measurement error evaluated for the +explanatory variable of this pixel as, per construction, :math:`\sigma_{z_{dh}} = 1`: + +.. math:: + \sigma_{dh}(\mathbb{P}}) = 1 \cdot \sigma_{dh}(\textrm{var}_{1}(\mathbb{P}, \textrm{var}_{2}(\mathbb{P}), ...) + +For a mean of pixels :math:`\overline{dh}_{mathbb{S}}` of the subsample :math:`\mathbb{S}`, the standard error of the mean +of the standardized data :math:`\overline{\sigma_{z_{dh}}}_{\mathbb{S}}` can be de-standardized by multiplying by the +average measurement error of the pixels in the subsample (evaluated through the explanatory variables of each pixel): + +.. math:: + \sigma_{\overline{dh_{\mathbb{S}}}} = \overline{\sigma_{z_{dh}}}_{\mathbb{S}} \cdot \overline{\sigma_{dh}(\textrm{var}_{1}, \textrm{var}_{2}, ...)}_{\mathbb{S}} + +Estimating the standard error of the mean of the standardized data :math:`\overline{\sigma_{z_{dh}}}_{\mathbb{S}}` requires +an analysis of spatial correlation and a spatial integration of this correlation, described in the next sections. TODO: Add a new gallery example From ae6bc45b167e6b345ca9864e5c2e5f92151078f0 Mon Sep 17 00:00:00 2001 From: rhugonne Date: Fri, 30 Jul 2021 01:49:28 +0200 Subject: [PATCH 093/113] incremental commit on documentation --- docs/source/spatialstats.rst | 38 +++++++++++++++++------------------- 1 file changed, 18 insertions(+), 20 deletions(-) diff --git a/docs/source/spatialstats.rst b/docs/source/spatialstats.rst index f36ef873..1f2828a9 100644 --- a/docs/source/spatialstats.rst +++ b/docs/source/spatialstats.rst @@ -83,6 +83,10 @@ Workflow for DEM precision estimation Non-stationarity in elevation measurement errors ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +.. minigallery:: xdem.spatialstats.nd_binning + :add-heading: Examples that deal with non-stationarities + :heading-level: " + Quantify and model non-stationarites """""""""""""""""""""""""""""""""""" @@ -92,7 +96,7 @@ In statistical terms, it corresponds to an `heteroscedasticity `_. + - the terrain slope and terrain curvature (see :ref:`terrain_attributes) that can explain a large part of the terrain-related variability in measurement error, + - the quality of stereo-correlation that can explain a large part of the measurement error of DEMs generated by stereophotogrammetry, + - the interferometric coherence that can explain a large part of the measurement error of DEMs generated by `InSAR `_. Standardize elevation differences for further analysis """""""""""""""""""""""""""""""""""""""""""""""""""""" @@ -118,32 +119,29 @@ further analysis (see :ref:`spatialstats_intro`), standardization of the elevati reach a stationary variance. .. math:: - z_{dh} = \frac{dh(\textrm{var}_{1}, \textrm{var}_{2}, ...)}{\sigma_{dh}(\textrm{var}_{1}, \textrm{var}_{2}, ...)} + z_{dh} = \frac{dh(\textrm{var}_{1}, \textrm{var}_{2}, \textrm{...})}{\sigma_{dh}(\textrm{var}_{1}, \textrm{var}_{2}, \textrm{...})} To de-standardize later estimations of the dispersion of a given subsample of elevation differences, possibly after further analysis of :ref:`spatialstats_corr` and :ref:`spatialstats_errorpropag`, one simply needs to apply the opposite operation. -For a single pixel :math:`\mathbb{P}`, the dispersion is directly the elevation measurement error evaluated for the +For a single pixel :math:`\textrm{P}`, the dispersion is directly the elevation measurement error evaluated for the explanatory variable of this pixel as, per construction, :math:`\sigma_{z_{dh}} = 1`: .. math:: - \sigma_{dh}(\mathbb{P}}) = 1 \cdot \sigma_{dh}(\textrm{var}_{1}(\mathbb{P}, \textrm{var}_{2}(\mathbb{P}), ...) + \sigma_{dh}(\textrm{P}) = 1 \cdot \sigma_{dh}(\textrm{var}_{1}(\textrm{P}), \textrm{var}_{2}(\textrm{P}), \textrm{...}) -For a mean of pixels :math:`\overline{dh}_{mathbb{S}}` of the subsample :math:`\mathbb{S}`, the standard error of the mean -of the standardized data :math:`\overline{\sigma_{z_{dh}}}_{\mathbb{S}}` can be de-standardized by multiplying by the -average measurement error of the pixels in the subsample (evaluated through the explanatory variables of each pixel): +For a mean of pixels :math:`\overline{dh}\vert_{\mathbb{S}}` in the subsample :math:`\mathbb{S}`, the standard error of the mean +of the standardized data :math:`\overline{\sigma_{z_{dh}}}\vert_{\mathbb{S}}` can be de-standardized by multiplying by the +average measurement error of the pixels in the subsample, evaluated through the explanatory variables of each pixel: .. math:: - \sigma_{\overline{dh_{\mathbb{S}}}} = \overline{\sigma_{z_{dh}}}_{\mathbb{S}} \cdot \overline{\sigma_{dh}(\textrm{var}_{1}, \textrm{var}_{2}, ...)}_{\mathbb{S}} + \sigma_{\overline{dh}}\vert_{\mathbb{S}} = \overline{\sigma_{z_{dh}}}\vert_{\mathbb{S}} \cdot \overline{\sigma_{dh}(\textrm{var}_{1}, \textrm{var}_{2}, \textrm{...})}\vert_{\mathbb{S}} -Estimating the standard error of the mean of the standardized data :math:`\overline{\sigma_{z_{dh}}}_{\mathbb{S}}` requires +Estimating the standard error of the mean of the standardized data :math:`\overline{\sigma_{z_{dh}}}\vert_{\mathbb{S}}` requires an analysis of spatial correlation and a spatial integration of this correlation, described in the next sections. -TODO: Add a new gallery example - -.. minigallery:: xdem.spatialstats.nd_binning - :add-heading: Example that deal with non-stationarities +TODO: Add a gallery example on the standardization .. _spatialstats_corr: @@ -154,7 +152,7 @@ TODO: Add this section based Rolstad et al. (2009), Dehecq et al. (2020), Hugonn .. minigallery:: xdem.spatialstats.sample_multirange_variogram :add-heading: Examples that deal with spatial correlations - + :heading-level: " Quantify spatial correlations """"""""""""""""""""""""""""" From 28c1d90f58d78fe217014a35e8dfb4e1c89f841a Mon Sep 17 00:00:00 2001 From: rhugonne Date: Fri, 30 Jul 2021 10:52:07 +0200 Subject: [PATCH 094/113] incremental commit on documentation --- docs/source/spatialstats.rst | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/docs/source/spatialstats.rst b/docs/source/spatialstats.rst index 1f2828a9..f29074f4 100644 --- a/docs/source/spatialstats.rst +++ b/docs/source/spatialstats.rst @@ -83,6 +83,12 @@ Workflow for DEM precision estimation Non-stationarity in elevation measurement errors ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +Elevation data has been shown to contain significant non-stationarities in elevation measurement errors (`Hugonnet +et al. (2021) `_, Hugonnet et al. (in prep)). + +``xdem`` contains method to **quantify** these non-stationarities along several explanatory variables, +**model** those numerically to estimate an elevation measurement error, and **standardize** them for further analysis. + .. minigallery:: xdem.spatialstats.nd_binning :add-heading: Examples that deal with non-stationarities :heading-level: " @@ -98,7 +104,6 @@ of elevation observations. .. math:: \sigma_{dh} = \sigma_{dh}(\textrm{var}_{1},\textrm{var}_{2}, \textrm{...}) \neq \textrm{constant} - Owing to the large number of samples of elevation data, we can easily estimate this variability by `binning * `_ the data and estimating the statistical dispersion for these explanatory variables (see :ref:`robuststats_meanstd`): @@ -136,10 +141,10 @@ of the standardized data :math:`\overline{\sigma_{z_{dh}}}\vert_{\mathbb{S}}` ca average measurement error of the pixels in the subsample, evaluated through the explanatory variables of each pixel: .. math:: - \sigma_{\overline{dh}}\vert_{\mathbb{S}} = \overline{\sigma_{z_{dh}}}\vert_{\mathbb{S}} \cdot \overline{\sigma_{dh}(\textrm{var}_{1}, \textrm{var}_{2}, \textrm{...})}\vert_{\mathbb{S}} + \sigma_{\overline{dh}}\vert_{\mathbb{S}} = \sigma_{\overline{z_{dh}}}\vert_{\mathbb{S}} \cdot \overline{\sigma_{dh}(\textrm{var}_{1}, \textrm{var}_{2}, \textrm{...})}\vert_{\mathbb{S}} -Estimating the standard error of the mean of the standardized data :math:`\overline{\sigma_{z_{dh}}}\vert_{\mathbb{S}}` requires -an analysis of spatial correlation and a spatial integration of this correlation, described in the next sections. +Estimating the standard error of the mean of the standardized data :math:`\sigma_{\overline{z_{dh}}}\vert_{\mathbb{S}}` +requires an analysis of spatial correlation and a spatial integration of this correlation, described in the next sections. TODO: Add a gallery example on the standardization @@ -148,7 +153,12 @@ TODO: Add a gallery example on the standardization Spatial correlation of elevation measurement errors ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -TODO: Add this section based Rolstad et al. (2009), Dehecq et al. (2020), Hugonnet et al. (in prep) +Spatial correlation of elevation measurement errors correspond to a dependency between measurement errors of spatially +close pixels in elevation data. Those can be related to the resolution of the data (short-range correlation), or to +instrument noise and deformations (mid- to long-range correlations). + +``xdem`` contains method to **quantify** these spatial correlation with pairwise sampling optimized for grid data and to +**model** correlations simultaneously at multiple ranges. .. minigallery:: xdem.spatialstats.sample_multirange_variogram :add-heading: Examples that deal with spatial correlations From 4e52840560b1048bf50ae5d0893be3146fe2b667 Mon Sep 17 00:00:00 2001 From: rhugonne Date: Fri, 30 Jul 2021 12:20:22 +0200 Subject: [PATCH 095/113] incremental commit on documentation --- docs/source/spatialstats.rst | 36 ++++++++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/docs/source/spatialstats.rst b/docs/source/spatialstats.rst index f29074f4..dde59c76 100644 --- a/docs/source/spatialstats.rst +++ b/docs/source/spatialstats.rst @@ -126,7 +126,9 @@ reach a stationary variance. .. math:: z_{dh} = \frac{dh(\textrm{var}_{1}, \textrm{var}_{2}, \textrm{...})}{\sigma_{dh}(\textrm{var}_{1}, \textrm{var}_{2}, \textrm{...})} -To de-standardize later estimations of the dispersion of a given subsample of elevation differences, +where :math:`z_{dh}` is the standardized elevation difference sample. + +To later de-standardize estimations of the dispersion of a given subsample of elevation differences, possibly after further analysis of :ref:`spatialstats_corr` and :ref:`spatialstats_errorpropag`, one simply needs to apply the opposite operation. @@ -167,7 +169,37 @@ instrument noise and deformations (mid- to long-range correlations). Quantify spatial correlations """"""""""""""""""""""""""""" -Estimate empirical variogram: +`Variograms `_ are functions that describe the spatial correlation of a sample. +The variogram :math:`2\gamma(h)` is a function of the distance between two points, referred to as spatial lag :math:`l` +(usually noted :math:`h`, here avoided to avoid confusion with the elevation and elevation differences). +The output of a variogram is the correlated variance of the sample. + +.. math:: + 2\gamma(l) = \textrm{var}\left(Z(\textrm{s}_{1}) - Z(\textrm{s}_{2})\right) + +where :math:`Z(\textrm{s}_{i})` is the value taken by the sample at location :math:`\textrm{s}_{i}`, and sample positions +:math:`\textrm{s}_{1}` and :math:`\textrm{s}_{2}` are separated by a distance :math:`l`. + +For elevation differences :math:`dh`, this translates into: + +.. math:: + 2\gamma_{dh}(l) = \textrm{var}\left(dh(\textrm{s}_{1}) - dh(\textrm{s}_{2})\right) + +The variogram essentially describes the spatial covariance :math:`C` in relation to the variance of the entire sample +:math:`\sigma_{dh}^{2}`: + +.. math:: + \gamma_{dh}(l) = \sigma_{dh}^{2} - C_{dh}(l) + +Historically, variograms have been estimated using point data and using all possible pairwise differences in the samples. +This amounts to :math:`N^2` pairwise calculations when using :math:`N` samples, which is not particularly suited to grid +data that contains many millions of points. Random subsampling of a large grid is unsatisfactory as it creates a +specific clustering of pairwise samples, that unevenly represents lag classes (many samples at mid distances, but too +few at short distances and long distances). +To remedy this issue, ``xdem`` encapsulates a subsampling method described in `skgstat.MetricSpace.RasterEquidistantMetricSpace` +which subsamples grid data with pairwise distances evenly distributed both across the grid and across 2D lag classes +(in 2D, lag classes separated by a factor of :math:`\sqrt{2}` have an equal number of pairwise differences computed). + .. literalinclude:: code/spatialstats.py :lines: 24-25 From c6a6d02d55b642c67651a18536daabbd81c3241a Mon Sep 17 00:00:00 2001 From: rhugonne Date: Fri, 30 Jul 2021 16:34:11 +0200 Subject: [PATCH 096/113] incremental commit on documentation --- docs/source/spatialstats.rst | 196 +++++++++++++++++++++-------------- 1 file changed, 121 insertions(+), 75 deletions(-) diff --git a/docs/source/spatialstats.rst b/docs/source/spatialstats.rst index dde59c76..abc04c27 100644 --- a/docs/source/spatialstats.rst +++ b/docs/source/spatialstats.rst @@ -14,10 +14,10 @@ partly on the package `scikit-gstat `_. That is, if the three following assumptions are verified: -1. The mean of the variable of interest is stationary in space, i.e. constant over sufficiently large areas, -2. The variance of the variable of interest is stationary in space, i.e. constant over sufficiently large areas. -3. The covariance between two observations only depends on the spatial distance between them, i.e. no other factor than this distance plays a role in the spatial correlation of measurement errors. - -A sufficiently large averaging area is an area expected to fit within the spatial domain studied. + 1. The mean of the variable of interest is stationary in space, i.e. constant over sufficiently large areas, + 2. The variance of the variable of interest is stationary in space, i.e. constant over sufficiently large areas. + 3. The covariance between two observations only depends on the spatial distance between them, i.e. no other factor than this distance plays a role in the spatial correlation of measurement errors. In other words, for a reliable analysis, the DEM should: -1. Not contain systematic biases that do not average out over sufficiently large distances (e.g., shifts, tilts), but can contain pseudo-periodic biases (e.g., along-track undulations), -2. Not contain measurement errors that vary significantly in space. -3. Not contain factors that significantly affect the distribution of measurement errors, except for the spatial distance. + 1. Not contain systematic biases that do not average out over sufficiently large distances (e.g., shifts, tilts), but can contain pseudo-periodic biases (e.g., along-track undulations), + 2. Not contain measurement errors that vary significantly in space. + 3. Not contain factors that significantly affect the distribution of measurement errors, except for the spatial distance. Quantifying the precision of a single DEM, or of a difference of DEMs ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -75,6 +73,56 @@ including moving terrain that is generally of greater interest for analysis. As shown in Hugonnet et al. (in prep), accounting for :ref:`spatialstats_nonstationarity` is needed to reliably use stable terrain as a proxy for other types of terrain. +.. _spatialstats_metrics: + +Metrics for DEM precision +------------------------- + +Historically, the precision of DEMs has been reported as a single value indicating the random error at the scale of a +single pixel, for example :math:`\pm 2` meters at the 1\ :math:`\sigma` `confidence level `_. + +However, there is some limitations to this simple metric: + + - the variability of the pixel-wise precision is not reported. The pixel-wise precision can vary depending on terrain- or instrument-related factors, such as the terrain slope. In rare occurences, part of this variability has been accounted in recent DEM products, such as TanDEM-X global DEM that partitions the precision between flat and steep slopes (`Rizzoli et al. (2017) `_), + - the area-wise precision of a DEM is generally not reported. Depending on the inherent resolution of the DEM, and patterns of noise that might plague the observations, the precision of a DEM over a surface area can vary significantly. + +Pixel-wise elevation measurement error +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The pixel-wise measurement error corresponds directly to the dispersion :math:`\sigma_{dh}` of the sample :math:`dh`. + +To estimate the pixel-wise measurement error for elevation data, two issues arise: + + 1. The dispersion :math:`\sigma_{dh}` cannot be estimated directly on changing terrain, + 2. The dispersion :math:`\sigma_{dh}` can show important non-stationarities. + +In :ref:`spatialstats_nonstationarity`, we describe how to quantify the measurement error as a function of +several explanatory variables by using stable terrain as a proxy. + +Spatially-integrated elevation measurement error +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The `standard error `_ of a statistic is the dispersion of the +distribution of this statistic. For spatially distributed samples, the standard error of the mean corresponds to the +error of a mean (or sum) of samples in space. + +The standard error :math:`\sigma_{\overline{dh}}` of the mean :math:`\overline{dh}` of the elevation changes +samples :math:`dh` can be written as: + +.. math:: + + \sigma_{\overline{dh}} = \frac{\sigma_{dh}}{\sqrt{N}}, + +where :math:`\sigma_{dh}` is the dispersion of the samples, and :math:`N` is the number of **independent** observations. + +To estimate the standard error of the mean for elevation data, two issue arises: + + 1. The dispersion of elevation differences :math:`\sigma_{dh}` is not stationary, a necessary assumption for spatial statistics. + 2. The number of pixels in the DEM :math:`N` does not equal the number of independent observations in the DEMs, because of spatial correlations. + +In :ref:`spatialstats_corr` and `spatialstats_errorpropag`, we describe how to account for spatial correlations and +use those to estimate a number of effective samples to integrate and propagate measurement errors in space. + Workflow for DEM precision estimation ------------------------------------- @@ -83,16 +131,12 @@ Workflow for DEM precision estimation Non-stationarity in elevation measurement errors ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -Elevation data has been shown to contain significant non-stationarities in elevation measurement errors (`Hugonnet +Elevation data contains significant non-stationarities in elevation measurement errors (`Hugonnet et al. (2021) `_, Hugonnet et al. (in prep)). -``xdem`` contains method to **quantify** these non-stationarities along several explanatory variables, +``xdem`` provides tools to **quantify** these non-stationarities along several explanatory variables, **model** those numerically to estimate an elevation measurement error, and **standardize** them for further analysis. -.. minigallery:: xdem.spatialstats.nd_binning - :add-heading: Examples that deal with non-stationarities - :heading-level: " - Quantify and model non-stationarites """""""""""""""""""""""""""""""""""" @@ -104,18 +148,27 @@ of elevation observations. .. math:: \sigma_{dh} = \sigma_{dh}(\textrm{var}_{1},\textrm{var}_{2}, \textrm{...}) \neq \textrm{constant} -Owing to the large number of samples of elevation data, we can easily estimate this variability by `binning * -`_ the data and estimating the statistical dispersion for these -explanatory variables (see :ref:`robuststats_meanstd`): +Owing to the large number of samples of elevation data, we can easily estimate this variability by `binning +`_ the data and estimating the statistical dispersion (see +:ref:`robuststats_meanstd`) across several explanatory variables using :func:`xdem.spatialstats.nd_binning`. .. literalinclude:: code/spatialstats.py - :lines: 17-19 + :lines: 18-19 + :language: python The significant explanatory variables typically are: + - the terrain slope and terrain curvature (see :ref:`terrain_attributes) that can explain a large part of the terrain-related variability in measurement error, - the quality of stereo-correlation that can explain a large part of the measurement error of DEMs generated by stereophotogrammetry, - the interferometric coherence that can explain a large part of the measurement error of DEMs generated by `InSAR `_. +Once quantified, the non-stationarities can be modelled numerically by linear interpolation across several +variables using :func:`xdem.spatialstats.interp_nd_binning`. + +.. literalinclude:: code/spatialstats.py + :lines: 20 + :language: python + Standardize elevation differences for further analysis """""""""""""""""""""""""""""""""""""""""""""""""""""" @@ -150,6 +203,10 @@ requires an analysis of spatial correlation and a spatial integration of this co TODO: Add a gallery example on the standardization +.. minigallery:: xdem.spatialstats.nd_binning + :add-heading: Examples that deal with non-stationarities + :heading-level: " + .. _spatialstats_corr: Spatial correlation of elevation measurement errors @@ -159,13 +216,9 @@ Spatial correlation of elevation measurement errors correspond to a dependency b close pixels in elevation data. Those can be related to the resolution of the data (short-range correlation), or to instrument noise and deformations (mid- to long-range correlations). -``xdem`` contains method to **quantify** these spatial correlation with pairwise sampling optimized for grid data and to +``xdem`` provides tools to **quantify** these spatial correlation with pairwise sampling optimized for grid data and to **model** correlations simultaneously at multiple ranges. -.. minigallery:: xdem.spatialstats.sample_multirange_variogram - :add-heading: Examples that deal with spatial correlations - :heading-level: " - Quantify spatial correlations """"""""""""""""""""""""""""" @@ -191,33 +244,63 @@ The variogram essentially describes the spatial covariance :math:`C` in relation .. math:: \gamma_{dh}(l) = \sigma_{dh}^{2} - C_{dh}(l) -Historically, variograms have been estimated using point data and using all possible pairwise differences in the samples. -This amounts to :math:`N^2` pairwise calculations when using :math:`N` samples, which is not particularly suited to grid -data that contains many millions of points. Random subsampling of a large grid is unsatisfactory as it creates a -specific clustering of pairwise samples, that unevenly represents lag classes (many samples at mid distances, but too +Empirical variograms are variograms estimated directly by `binned `_ analysis +of variance of the data. Historically, empirical variograms were estimated for point data by calculating all possible +pairwise differences in the samples. This amounts to :math:`N^2` pairwise calculations for :math:`N` samples, which is +not well-suited to grid data that contains many millions of points and would be impossible to comupute. Thus, in order +to estimate a variogram for large grid data, subsampling is necessary. + +Random subsampling of the grid samples used is a solution, but often unsatisfactory as it creates a clustering +of pairwise samples that unevenly represents lag classes (most pairwise differences are found at mid distances, but too few at short distances and long distances). -To remedy this issue, ``xdem`` encapsulates a subsampling method described in `skgstat.MetricSpace.RasterEquidistantMetricSpace` -which subsamples grid data with pairwise distances evenly distributed both across the grid and across 2D lag classes -(in 2D, lag classes separated by a factor of :math:`\sqrt{2}` have an equal number of pairwise differences computed). +To remedy this issue, ``xdem`` provides an empirical variogram estimation tool :func:`xdem.spatialstats.sample_empirical_variogram` + that encapsulates a pairwise subsampling method described in ``skgstat.MetricSpace.RasterEquidistantMetricSpace``. +This method compares pairwise distances between a center subset and equidistant subsets iteratively across a grid, based on +`sparse matrices `_ routines computing pairwise distances of two separate +subsets, as in `scipy.cdist `_ +(instead of using pairwise distances within the same subset, as implemented in most spatial statistics packages). +The resulting pairwise differences are evenly distributed across the grid and across lag classes (in 2 dimensions, this +means that lag classes separated by a factor of :math:`\sqrt{2}` have an equal number of pairwise differences computed). .. literalinclude:: code/spatialstats.py - :lines: 24-25 + :lines: 25-26 + :language: python + +The variogram is returned as a ``pd.Dataframe`` object. + +With all spatial lags sampled evenly, estimating a variogram requires significantly less samples, increasing the +robustness of the spatial correlation estimation and decreasing computing time! Model spatial correlations """""""""""""""""""""""""" -Fit a multiple-range model: +Once an empirical variogram is estimated, fitting a function model allows to simplify later analysis by directly +providing a function form (e.g., for kriging equations, or uncertainty analysis - see :ref:`spatialstats_errorpropag`), +which would otherwise have to be numerically modelled. + +Generally, in spatial statistics, a single model is used to describe the correlation in the data. +In elevation data, however, spatial correlations are observed at different scales, which requires fitting a sum of models at +multiple ranges (introduced in `Rolstad et al. (2009) `_ for glaciology +applications). + +This can be performed through the function :func:`xdem.spatialstats.fit_sum_model_variogram`, which expects as input a +``pd.Dataframe`` variogram. .. literalinclude:: code/spatialstats.py - :lines: 27-28 + :lines: 28 + :language: python + +.. minigallery:: xdem.spatialstats.sample_empirical_variogram + :add-heading: Examples that deal with spatial correlations + :heading-level: " .. _spatialstats_errorpropag: Spatially integrated measurement errors ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -Deduce an effective sample size, and elevation measurement error: +After quantifying and modelling spatial correlations, those an effective sample size, and elevation measurement error: .. literalinclude:: code/spatialstats.py :lines: 30-33 @@ -230,40 +313,3 @@ Propagation of correlated errors TODO: Add this section based on Krige's relation (Webster & Oliver, 2007), Hugonnet et al. (in prep) -Metrics for DEM precision -------------------------- - -Historically, the precision of DEMs has been reported as a single value indicating the random error at the scale of a single pixel, for example :math:`\pm 2` meters. - -However, there is several limitations to this metric: - -- studies have shown significant variability of elevation measurement errors with terrain attributes, such as the slope, but also with the type of terrain - - -Pixel-wise elevation measurement error -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -TODO - - -Spatially-integrated elevation measurement error -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -The standard error (SE) of a statistic is the standard deviation of the distribution of this statistic. -For spatially distributed samples, the standard error of the mean (SEM) is of great interest as it allows quantification of the error of a mean (or sum) of samples in space. - -The standard error :math:`\sigma_{\overline{dh}}` of the mean :math:`\overline{dh}` of elevation changes samples :math:`dh` is typically derived as: - -.. math:: - - \sigma_{\overline{dh}} = \frac{\sigma_{dh}}{\sqrt{N}}, - -where :math:`\sigma_{dh}` is the dispersion of the samples, and :math:`N` is the number of **independent** observations. - -However, several issues arise to estimate the standard error of a mean of elevation observations samples: - -1. The dispersion :math:`\sigma_{dh}` cannot be estimated directly on changing terrain that we are usually interested in measuring (e.g., glacier, snow, forest). -2. The dispersion :math:`\sigma_{dh}` typically shows important non-stationarities (e.g., an error 10 times as large on steep slopes than flat slopes). -3. The number of samples :math:`N` is generally not equal to the number of sampled DEM pixels, as those are not independent in space and the Ground Sampling Distance of the DEM does not necessarily correspond to its effective resolution. - -Note that the SE represents completely stochastic (random) errors, and is therefore not accounting for possible remaining systematic errors have been accounted for, e.g. using one or multiple :ref:`coregistration` approaches. From fb25a2aa4746fb59ddbb152cbc7a3ffdc8dabcf0 Mon Sep 17 00:00:00 2001 From: rhugonne Date: Fri, 30 Jul 2021 16:34:53 +0200 Subject: [PATCH 097/113] refactor variogram function names --- docs/source/code/spatialstats.py | 6 +++--- docs/source/intro.rst | 2 +- examples/plot_vgm_error.py | 14 +++++++------- tests/test_spatialstats.py | 24 ++++++++++++------------ xdem/spatialstats.py | 18 +++++++++--------- 5 files changed, 32 insertions(+), 32 deletions(-) diff --git a/docs/source/code/spatialstats.py b/docs/source/code/spatialstats.py index 8f2b7712..17e29fdc 100644 --- a/docs/source/code/spatialstats.py +++ b/docs/source/code/spatialstats.py @@ -22,10 +22,10 @@ err_dh = xdem.spatialstats.interp_nd_binning(df_ns, list_var_names=['slope']) # Sample empirical variogram -df_vgm = xdem.spatialstats.sample_multirange_variogram(values=dh.data, gsd=dh.res[0], subsample=50, - random_state=42, runs=10) +df_vgm = xdem.spatialstats.sample_empirical_variogram(values=dh.data, gsd=dh.res[0], subsample=50, + random_state=42, runs=10) # Fit sum of double-range spherical model -fun, coefs = xdem.spatialstats.fit_sum_variogram(list_model=['Sph', 'Sph'], empirical_variogram=df_vgm) +fun, coefs = xdem.spatialstats.fit_sum_model_variogram(list_model=['Sph', 'Sph'], empirical_variogram=df_vgm) # Calculate the area-averaged uncertainty with these models list_vgm = [] diff --git a/docs/source/intro.rst b/docs/source/intro.rst index 72c00ff0..02f6e16e 100644 --- a/docs/source/intro.rst +++ b/docs/source/intro.rst @@ -92,6 +92,6 @@ statistical measures, to allow accurate DEM uncertainty estimation for everyone. The tools for quantifying DEM precision are described in :ref:`spatialstats`. -.. minigallery:: xdem.spatialstats.sample_multirange_variogram xdem.spatialstats.nd_binning +.. minigallery:: xdem.spatialstats.sample_empirical_variogram xdem.spatialstats.nd_binning :add-heading: Examples that use spatial statistics functions diff --git a/examples/plot_vgm_error.py b/examples/plot_vgm_error.py index 42300d70..568051d6 100644 --- a/examples/plot_vgm_error.py +++ b/examples/plot_vgm_error.py @@ -18,9 +18,9 @@ Here, we show an example in which we estimate spatially integrated elevation measurement errors for a DEM difference of Longyearbyen glacier, demonstrated in :ref:`sphx_glr_auto_examples_plot_nuth_kaab.py`. We first quantify the spatial -correlations using :func:`xdem.spatialstats.sample_multirange_variogram` based on routines of `scikit-gstat +correlations using :func:`xdem.spatialstats.sample_empirical_variogram` based on routines of `scikit-gstat `_. We then model the empirical variogram using a sum of variogram -models using :func:`xdem.spatialstats.fit_sum_variogram`. +models using :func:`xdem.spatialstats.fit_sum_model_variogram`. Finally, we integrate the variogram models for varying surface areas to estimate the spatially integrated elevation measurement errors using :func:`xdem.spatialstats.neff_circ`, and empirically validate the improved robustness of our results using :func:`xdem.spatialstats.patches_method`, an intensive Monte-Carlo sampling approach. @@ -84,9 +84,9 @@ # # To perform this procedure effectively, we use improved methods that provide efficient pairwise sampling methods for # large grid data in `scikit-gstat `_, which are encapsulated -# conveniently by :func:`xdem.spatialstats.sample_multirange_variogram`: +# conveniently by :func:`xdem.spatialstats.sample_empirical_variogram`: -df = xdem.spatialstats.sample_multirange_variogram( +df = xdem.spatialstats.sample_empirical_variogram( values=dh.data, gsd=dh.res[0], subsample=50, runs=30, n_variograms=10, estimator='cressie', random_state=42) # %% @@ -122,11 +122,11 @@ # # In order to show the difference between accounting only for the most noticeable, short-range correlation, or adding the # long-range correlation, we fit this empirical variogram with two different models: a single spherical model, and -# the sum of two spherical models (two ranges). For this, we use :func:`xdem.spatialstats.fit_sum_variogram`, which +# the sum of two spherical models (two ranges). For this, we use :func:`xdem.spatialstats.fit_sum_model_variogram`, which # is based on `scipy.optimize.curve_fit `_: -fun, params1 = xdem.spatialstats.fit_sum_variogram(['Sph'], empirical_variogram=df) +fun, params1 = xdem.spatialstats.fit_sum_model_variogram(['Sph'], empirical_variogram=df) -fun2, params2 = xdem.spatialstats.fit_sum_variogram(['Sph', 'Sph'], empirical_variogram=df) +fun2, params2 = xdem.spatialstats.fit_sum_model_variogram(['Sph', 'Sph'], empirical_variogram=df) xdem.spatialstats.plot_vgm(df,list_fit_fun=[fun, fun2],list_fit_fun_label=['Single-range model', 'Double-range model'], xscale_range_split=[100, 1000, 10000]) diff --git a/tests/test_spatialstats.py b/tests/test_spatialstats.py index e05c8961..16f41a3c 100644 --- a/tests/test_spatialstats.py +++ b/tests/test_spatialstats.py @@ -38,7 +38,7 @@ def test_sample_multirange_variogram_default(self): diff, mask = load_ref_and_diff()[1:3] # Check the variogram estimation runs for a random state - df = xdem.spatialstats.sample_multirange_variogram( + df = xdem.spatialstats.sample_empirical_variogram( values=diff.data, gsd=diff.res[0], subsample=50, random_state=42, runs=2) @@ -48,7 +48,7 @@ def test_sample_multirange_variogram_default(self): assert all(np.isnan(df.err_exp.values)) # Test multiple runs - df2 = xdem.spatialstats.sample_multirange_variogram( + df2 = xdem.spatialstats.sample_empirical_variogram( values=diff.data, gsd=diff.res[0], subsample=50, random_state=42, runs=2, n_variograms=2) @@ -67,7 +67,7 @@ def test_sample_multirange_variogram_methods(self, subsample_method): diff, mask = load_ref_and_diff()[1:3] # Check the variogram estimation runs for several methods - df = xdem.spatialstats.sample_multirange_variogram( + df = xdem.spatialstats.sample_empirical_variogram( values=diff.data, gsd=diff.res[0], subsample=50, random_state=42, subsample_method=subsample_method) @@ -86,29 +86,29 @@ def test_sample_multirange_variogram_args(self): # Check the function raises a warning for optional arguments incorrect to the method with pytest.warns(UserWarning): # An argument only use by cdist with a pdist method - df = xdem.spatialstats.sample_multirange_variogram( + df = xdem.spatialstats.sample_empirical_variogram( values=diff.data, gsd=diff.res[0], subsample=50, random_state=42, subsample_method='pdist_ring', **cdist_args) with pytest.warns(UserWarning): # Same here - df = xdem.spatialstats.sample_multirange_variogram( + df = xdem.spatialstats.sample_empirical_variogram( values=diff.data, gsd=diff.res[0], subsample=50, random_state=42, subsample_method='cdist_equidistant', runs=2, **pdist_args) with pytest.warns(UserWarning): # Should also raise a warning for a nonsense argument - df = xdem.spatialstats.sample_multirange_variogram( + df = xdem.spatialstats.sample_empirical_variogram( values=diff.data, gsd=diff.res[0], subsample=50, random_state=42, subsample_method='cdist_equidistant', runs=2, **nonsense_args) # Check the function passes optional arguments specific to pdist methods without warning - df = xdem.spatialstats.sample_multirange_variogram( + df = xdem.spatialstats.sample_empirical_variogram( values=diff.data, gsd=diff.res[0], subsample=50, random_state=42, subsample_method='pdist_ring', **pdist_args) # Check the function passes optional arguments specific to cdist methods without warning - df = xdem.spatialstats.sample_multirange_variogram( + df = xdem.spatialstats.sample_empirical_variogram( values=diff.data, gsd=diff.res[0], subsample=50, random_state=42, subsample_method='cdist_equidistant', runs=2, **cdist_args) @@ -136,7 +136,7 @@ def test_multirange_fit_performance(self): df = df.assign(bins=x, exp=y_simu, err_exp=sigma) # Run the fitting - fun, params_est = xdem.spatialstats.fit_sum_variogram(['Sph', 'Sph', 'Sph'], df) + fun, params_est = xdem.spatialstats.fit_sum_model_variogram(['Sph', 'Sph', 'Sph'], df) for i in range(len(params_est)): # Assert all parameters were correctly estimated within a 30% relative margin @@ -152,16 +152,16 @@ def test_empirical_fit_plotting(self): diff, mask = load_ref_and_diff()[1:3] # Check the variogram estimation runs for a random state - df = xdem.spatialstats.sample_multirange_variogram( + df = xdem.spatialstats.sample_empirical_variogram( values=diff.data, gsd=diff.res[0], subsample=50, random_state=42, runs=10) # Single model fit - fun, _ = xdem.spatialstats.fit_sum_variogram(['Sph'], df) + fun, _ = xdem.spatialstats.fit_sum_model_variogram(['Sph'], df) if PLOT: xdem.spatialstats.plot_vgm(df, list_fit_fun=[fun]) # Triple model fit - fun2, _ = xdem.spatialstats.fit_sum_variogram(['Sph', 'Sph', 'Sph'], empirical_variogram=df) + fun2, _ = xdem.spatialstats.fit_sum_model_variogram(['Sph', 'Sph', 'Sph'], empirical_variogram=df) if PLOT: xdem.spatialstats.plot_vgm(df, list_fit_fun=[fun2]) diff --git a/xdem/spatialstats.py b/xdem/spatialstats.py index a7bad710..2f313d79 100644 --- a/xdem/spatialstats.py +++ b/xdem/spatialstats.py @@ -357,7 +357,7 @@ def _aggregate_pdist_empirical_variogram(values: np.ndarray, coords: np.ndarray, pdist_multi_ranges: Optional[list[float]] = None, **kwargs) -> pd.DataFrame: """ (Not used by default) - Aggregating subfunction of sample_multirange_variogram for pdist methods. + Aggregating subfunction of sample_empirical_variogram for pdist methods. The pairwise differences are calculated within each subsample. """ @@ -532,11 +532,11 @@ def _wrapper_get_empirical_variogram(argdict: dict) -> pd.DataFrame: return get_variogram(**argdict) -def sample_multirange_variogram(values: Union[np.ndarray,RasterType], gsd: float = None, coords: np.ndarray = None, - subsample: int = 10000, subsample_method: str = 'cdist_equidistant', - n_variograms: int = 1, n_jobs: int = 1, verbose=False, - random_state: None | np.random.RandomState | np.random.Generator | int = None, - **kwargs) -> pd.DataFrame: +def sample_empirical_variogram(values: Union[np.ndarray, RasterType], gsd: float = None, coords: np.ndarray = None, + subsample: int = 10000, subsample_method: str = 'cdist_equidistant', + n_variograms: int = 1, n_jobs: int = 1, verbose=False, + random_state: None | np.random.RandomState | np.random.Generator | int = None, + **kwargs) -> pd.DataFrame: """ Sample empirical variograms with binning adaptable to multiple ranges and spatial subsampling adapted for raster data. By default, subsampling is based on RasterEquidistantMetricSpace implemented in scikit-gstat. This method samples more @@ -730,7 +730,7 @@ def sample_multirange_variogram(values: Union[np.ndarray,RasterType], gsd: float return df -def fit_sum_variogram(list_model: list[str], empirical_variogram: pd.DataFrame) -> tuple[Callable, list[float]]: +def fit_sum_model_variogram(list_model: list[str], empirical_variogram: pd.DataFrame) -> tuple[Callable, list[float]]: """ Fit a multi-range variogram model to an empirical variogram, weighted least-squares based on sampling errors @@ -1248,8 +1248,8 @@ def plot_vgm(df: pd.DataFrame, list_fit_fun: Optional[list[Callable[[float],floa xscale='linear', xscale_range_split: Optional[list] = None): """ Plot empirical variogram, and optionally also plot one or several model fits. - Input dataframe is expected to be the output of xdem.spatialstats.sample_multirange_variogram. - Input function model is expected to be the output of xdem.spatialstats.fit_sum_variogram. + Input dataframe is expected to be the output of xdem.spatialstats.sample_empirical_variogram. + Input function model is expected to be the output of xdem.spatialstats.fit_sum_model_variogram. :param df: dataframe of empirical variogram :param list_fit_fun: list of model function fits From 810dd093f748aa0463c4af2a7b7b5c43ae6a886f Mon Sep 17 00:00:00 2001 From: rhugonne Date: Fri, 30 Jul 2021 16:35:05 +0200 Subject: [PATCH 098/113] add subsections --- docs/source/robuststats.rst | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/source/robuststats.rst b/docs/source/robuststats.rst index 9cadc276..8023e749 100644 --- a/docs/source/robuststats.rst +++ b/docs/source/robuststats.rst @@ -17,6 +17,8 @@ to first resort to outlier filtering (see :ref:`filters`) and perform analysis u .. contents:: Contents :local: +.. _robuststats_meanstd: + Measures of central tendency and dispersion of a sample -------------------------------------------------------- @@ -63,6 +65,8 @@ When working with weighted data, the difference between the 84\ :sup:`th` and 16 The NMAD is used by default for estimating elevation measurement errors in :ref:`spatialstats`. +.. _robuststats_corr: + Measures of correlation ----------------------- @@ -91,6 +95,8 @@ where :math:`h` is the spatial lag and :math:`Z_{x_{i}}` is the value of the sam Dowd's variogram is used by default to estimate spatial auto-correlation of elevation measurement errors in :ref:`spatialstats`. +.. _robuststats_regression: + Regression analysis ------------------- From a63956abd1d20afac42f2f2f9e30f9e8df26c8b4 Mon Sep 17 00:00:00 2001 From: rhugonne Date: Fri, 30 Jul 2021 18:21:41 +0200 Subject: [PATCH 099/113] incremental commit on documentation --- docs/source/code/spatialstats.py | 6 ++--- .../spatialstats_nonstationarity_slope.py | 22 ++++++++++++++++ .../code/spatialstats_variogram_covariance.py | 25 +++++++++++++++++++ docs/source/spatialstats.rst | 20 +++++++++------ 4 files changed, 63 insertions(+), 10 deletions(-) create mode 100644 docs/source/code/spatialstats_nonstationarity_slope.py create mode 100644 docs/source/code/spatialstats_variogram_covariance.py diff --git a/docs/source/code/spatialstats.py b/docs/source/code/spatialstats.py index 17e29fdc..0ba9833d 100644 --- a/docs/source/code/spatialstats.py +++ b/docs/source/code/spatialstats.py @@ -3,13 +3,13 @@ import geoutils as gu import numpy as np + # Load data dh = gu.georaster.Raster(xdem.examples.get_path("longyearbyen_ddem")) +ref_dem = xdem.DEM(xdem.examples.get_path("longyearbyen_ref_dem")) glacier_mask = gu.geovector.Vector(xdem.examples.get_path("longyearbyen_glacier_outlines")) mask = glacier_mask.create_mask(dh) - -# Get slope for non-stationarity -slope = xdem.terrain.get_terrain_attribute(dh.data, resolution=dh.res[0], attribute=['slope']) +slope = xdem.terrain.get_terrain_attribute(ref_dem.data, resolution=ref_dem.res[0], attribute=['slope']) # Keep only stable terrain data dh.data[mask] = np.nan diff --git a/docs/source/code/spatialstats_nonstationarity_slope.py b/docs/source/code/spatialstats_nonstationarity_slope.py new file mode 100644 index 00000000..b96c9d85 --- /dev/null +++ b/docs/source/code/spatialstats_nonstationarity_slope.py @@ -0,0 +1,22 @@ +"""Code example for spatial statistics""" +import xdem +import geoutils as gu +import numpy as np + +# Load data +dh = gu.georaster.Raster(xdem.examples.get_path("longyearbyen_ddem")) +ref_dem = xdem.DEM(xdem.examples.get_path("longyearbyen_ref_dem")) +glacier_mask = gu.geovector.Vector(xdem.examples.get_path("longyearbyen_glacier_outlines")) +mask = glacier_mask.create_mask(dh) + +# Get slope for non-stationarity +slope = xdem.terrain.get_terrain_attribute(dem=ref_dem.data, resolution=dh.res, attribute=['slope']) + +# Keep only stable terrain data +dh.data[mask] = np.nan + +# Estimate the measurement error by bin of slope, using the NMAD as robust estimator +df_ns = xdem.spatialstats.nd_binning(dh.data.ravel(), list_var=[slope.ravel()], list_var_names=['slope'], + statistics=['count', xdem.spatialstats.nmad], list_var_bins=30) + +xdem.spatialstats.plot_1d_binning(df_ns, 'slope', 'nmad', 'Slope (degrees)', 'NMAD of dh (m)') \ No newline at end of file diff --git a/docs/source/code/spatialstats_variogram_covariance.py b/docs/source/code/spatialstats_variogram_covariance.py new file mode 100644 index 00000000..b3c613f4 --- /dev/null +++ b/docs/source/code/spatialstats_variogram_covariance.py @@ -0,0 +1,25 @@ +import matplotlib.pyplot as plt +import numpy as np +import xdem + +# Example variogram function +def variogram_exp(h): + return xdem.spatialstats.vgm(h, 15, model='Exp', psill=10) + + +fig, ax = plt.subplots() +x = np.linspace(0,100,100) +ax.plot(x, variogram_exp(x), color='tab:blue', linewidth=2) +ax.plot(x, 10 - variogram_exp(x), color='black', linewidth=2) +ax.hlines(10, xmin=0, xmax=100, linestyles='dashed', colors='tab:red') +ax.text(75, variogram_exp(75)-1, 'Semi-variogram $\gamma(l)$', ha='center', va='top', color='tab:blue') +ax.text(75, 10 - variogram_exp(75) + 1, 'Covariance $C(l) = \sigma^{2} - \gamma(l)$', ha='center', va='bottom', color='black') +ax.text(75, 11, 'Variance $\sigma^{2}$', ha='center', va='bottom', color='tab:red') +ax.set_xlim((0, 100)) +ax.set_ylim((0, 12)) +ax.set_xlabel('Spatial lag $l$') +ax.set_ylabel('Variance of elevation differences (m²)') +ax.spines['right'].set_visible(False) +ax.spines['top'].set_visible(False) +plt.tight_layout() +plt.show() \ No newline at end of file diff --git a/docs/source/spatialstats.rst b/docs/source/spatialstats.rst index abc04c27..435dfb83 100644 --- a/docs/source/spatialstats.rst +++ b/docs/source/spatialstats.rst @@ -63,11 +63,11 @@ If the other elevation data is known to be of higher-precision, one can assume t Using stable terrain as a proxy ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -Stable terrain is the terrain that has supposedly not been subject to any elevation change. For example bare-rock, or -generally almost all terrain excluding glaciers, snow and forests. +Stable terrain is the terrain that has supposedly not been subject to any elevation change. It often refers to bare-rock, +and is generally computed by simply excluding glaciers, snow and forests. -Due to the sparsity of synchronous acquisitions, elevation data cannot be easily compared over similar periods. Thus, -when comparing elevation data, stable terrain is used a proxy to assess the precision of a DEM on all its terrain, +Due to the sparsity of synchronous acquisitions, elevation data cannot be easily compared for simultaneous acquisition +times. Thus, stable terrain is used a proxy to assess the precision of a DEM on all its terrain, including moving terrain that is generally of greater interest for analysis. As shown in Hugonnet et al. (in prep), accounting for :ref:`spatialstats_nonstationarity` is needed to reliably @@ -156,7 +156,10 @@ Owing to the large number of samples of elevation data, we can easily estimate t :lines: 18-19 :language: python -The significant explanatory variables typically are: +.. plot:: code/spatialstats_nonstationarity_slope.py + :width: 90% + +The most common explanatory variables are: - the terrain slope and terrain curvature (see :ref:`terrain_attributes) that can explain a large part of the terrain-related variability in measurement error, - the quality of stereo-correlation that can explain a large part of the measurement error of DEMs generated by stereophotogrammetry, @@ -244,6 +247,9 @@ The variogram essentially describes the spatial covariance :math:`C` in relation .. math:: \gamma_{dh}(l) = \sigma_{dh}^{2} - C_{dh}(l) +.. plot:: code/spatialstats_variogram_covariance.py + :width: 90% + Empirical variograms are variograms estimated directly by `binned `_ analysis of variance of the data. Historically, empirical variograms were estimated for point data by calculating all possible pairwise differences in the samples. This amounts to :math:`N^2` pairwise calculations for :math:`N` samples, which is @@ -254,8 +260,8 @@ Random subsampling of the grid samples used is a solution, but often unsatisfact of pairwise samples that unevenly represents lag classes (most pairwise differences are found at mid distances, but too few at short distances and long distances). -To remedy this issue, ``xdem`` provides an empirical variogram estimation tool :func:`xdem.spatialstats.sample_empirical_variogram` - that encapsulates a pairwise subsampling method described in ``skgstat.MetricSpace.RasterEquidistantMetricSpace``. +To remedy this issue, ``xdem`` provides :func:`xdem.spatialstats.sample_empirical_variogram`, an empirical variogram estimation tool +that encapsulates a pairwise subsampling method described in ``skgstat.MetricSpace.RasterEquidistantMetricSpace``. This method compares pairwise distances between a center subset and equidistant subsets iteratively across a grid, based on `sparse matrices `_ routines computing pairwise distances of two separate subsets, as in `scipy.cdist `_ From 56d7368766e2eff57ff553970d511f76fc92a7e6 Mon Sep 17 00:00:00 2001 From: rhugonne Date: Wed, 4 Aug 2021 14:07:56 +0200 Subject: [PATCH 100/113] add plot to illustrate non stationarity --- .../spatialstats_stationarity_assumption.py | 70 +++++++++++++++++++ docs/source/spatialstats.rst | 3 + 2 files changed, 73 insertions(+) create mode 100644 docs/source/code/spatialstats_stationarity_assumption.py diff --git a/docs/source/code/spatialstats_stationarity_assumption.py b/docs/source/code/spatialstats_stationarity_assumption.py new file mode 100644 index 00000000..4c07edbb --- /dev/null +++ b/docs/source/code/spatialstats_stationarity_assumption.py @@ -0,0 +1,70 @@ +import matplotlib.pyplot as plt +import numpy as np +import xdem + +# Example x vector +x = np.linspace(0,1,200) + +sig = 0.2 +np.random.seed(42) +y_rand1 = np.random.normal(0, sig, size=len(x)) +y_rand2 = np.random.normal(0, sig, size=len(x)) +y_rand3 = np.random.normal(0, sig, size=len(x)) + + +y_mean = np.array([0.5*xval - 0.25 if xval >0.5 else 0.5*(1-xval) - 0.25 for xval in x]) + +fac_y_std = 0.5 + 2*x + + +fig, (ax1, ax2, ax3) = plt.subplots(ncols=3, figsize=(8,4)) + +# Stationary mean and variance +ax1.plot(x, y_rand1, color='tab:blue', linewidth=0.5) +ax1.hlines(0, xmin=0, xmax=1, color='black', label='Mean', linestyle='dashed') +ax1.hlines([-2*sig, 2*sig], xmin=0, xmax=1, colors=['tab:gray','tab:gray'], label='Standard deviation',linestyles='dashed') +ax1.set_xlim((0,1)) +ax1.set_title('Stationary mean\nStationary variance') +# ax1.legend() +ax1.spines['right'].set_visible(False) +ax1.spines['top'].set_visible(False) +ax1.set_ylim((-1,1)) +ax1.set_xticks([]) +ax1.set_yticks([]) +ax1.plot(1, 0, ">k", transform=ax1.transAxes, clip_on=False) +ax1.plot(0, 1, "^k", transform=ax1.transAxes, clip_on=False) + +# Non-stationary mean and stationary variance +ax2.plot(x, y_rand2 + y_mean, color='tab:olive', linewidth=0.5) +ax2.plot(x, y_mean, color='black', label='Mean', linestyle='dashed') +ax2.plot(x, y_mean + 2*sig, color='tab:gray', label='Dispersion (2$\sigma$)', linestyle='dashed') +ax2.plot(x, y_mean - 2*sig, color='tab:gray', linestyle='dashed') +ax2.set_xlim((0,1)) +ax2.set_title('Non-stationary mean\nStationary variance') +ax2.legend(loc='lower center') +ax2.spines['right'].set_visible(False) +ax2.spines['top'].set_visible(False) +ax2.set_xticks([]) +ax2.set_yticks([]) +ax2.set_ylim((-1,1)) +ax2.plot(1, 0, ">k", transform=ax2.transAxes, clip_on=False) +ax2.plot(0, 1, "^k", transform=ax2.transAxes, clip_on=False) + +# Stationary mean and non-stationary variance +ax3.plot(x, y_rand3 * fac_y_std, color='tab:orange', linewidth=0.5) +ax3.hlines(0, xmin=0, xmax=1, color='black', label='Mean', linestyle='dashed') +ax3.plot(x, 2*sig*fac_y_std, color='tab:gray', linestyle='dashed') +ax3.plot(x, -2*sig*fac_y_std, color='tab:gray', linestyle='dashed') +ax3.set_xlim((0,1)) +ax3.set_title('Stationary mean\nNon-stationary variance') +# ax1.legend() +ax3.spines['right'].set_visible(False) +ax3.spines['top'].set_visible(False) +ax3.set_xticks([]) +ax3.set_yticks([]) +ax3.set_ylim((-1,1)) +ax3.plot(1, 0, ">k", transform=ax3.transAxes, clip_on=False) +ax3.plot(0, 1, "^k", transform=ax3.transAxes, clip_on=False) + +plt.tight_layout() +plt.show() \ No newline at end of file diff --git a/docs/source/spatialstats.rst b/docs/source/spatialstats.rst index 435dfb83..438faa72 100644 --- a/docs/source/spatialstats.rst +++ b/docs/source/spatialstats.rst @@ -38,6 +38,9 @@ That is, if the three following assumptions are verified: 2. The variance of the variable of interest is stationary in space, i.e. constant over sufficiently large areas. 3. The covariance between two observations only depends on the spatial distance between them, i.e. no other factor than this distance plays a role in the spatial correlation of measurement errors. +.. plot:: code/spatialstats_stationarity_assumption.py + :width: 90% + In other words, for a reliable analysis, the DEM should: 1. Not contain systematic biases that do not average out over sufficiently large distances (e.g., shifts, tilts), but can contain pseudo-periodic biases (e.g., along-track undulations), From c5aa17a7f48fc545d4a157046cd814d470439b78 Mon Sep 17 00:00:00 2001 From: rhugonne Date: Wed, 4 Aug 2021 14:33:42 +0200 Subject: [PATCH 101/113] fix doc tests --- .../code/spatialstats_stationarity_assumption.py | 2 +- .../source/code/spatialstats_variogram_covariance.py | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/source/code/spatialstats_stationarity_assumption.py b/docs/source/code/spatialstats_stationarity_assumption.py index 4c07edbb..50987926 100644 --- a/docs/source/code/spatialstats_stationarity_assumption.py +++ b/docs/source/code/spatialstats_stationarity_assumption.py @@ -37,7 +37,7 @@ # Non-stationary mean and stationary variance ax2.plot(x, y_rand2 + y_mean, color='tab:olive', linewidth=0.5) ax2.plot(x, y_mean, color='black', label='Mean', linestyle='dashed') -ax2.plot(x, y_mean + 2*sig, color='tab:gray', label='Dispersion (2$\sigma$)', linestyle='dashed') +ax2.plot(x, y_mean + 2*sig, color='tab:gray', label='Dispersion (2$\\sigma$)', linestyle='dashed') ax2.plot(x, y_mean - 2*sig, color='tab:gray', linestyle='dashed') ax2.set_xlim((0,1)) ax2.set_title('Non-stationary mean\nStationary variance') diff --git a/docs/source/code/spatialstats_variogram_covariance.py b/docs/source/code/spatialstats_variogram_covariance.py index b3c613f4..3a44afee 100644 --- a/docs/source/code/spatialstats_variogram_covariance.py +++ b/docs/source/code/spatialstats_variogram_covariance.py @@ -1,20 +1,20 @@ import matplotlib.pyplot as plt import numpy as np -import xdem # Example variogram function def variogram_exp(h): - return xdem.spatialstats.vgm(h, 15, model='Exp', psill=10) - + from xdem.spatialstats import vgm + val = vgm(h, 15, model='Exp', psill=10) + return val fig, ax = plt.subplots() x = np.linspace(0,100,100) ax.plot(x, variogram_exp(x), color='tab:blue', linewidth=2) ax.plot(x, 10 - variogram_exp(x), color='black', linewidth=2) ax.hlines(10, xmin=0, xmax=100, linestyles='dashed', colors='tab:red') -ax.text(75, variogram_exp(75)-1, 'Semi-variogram $\gamma(l)$', ha='center', va='top', color='tab:blue') -ax.text(75, 10 - variogram_exp(75) + 1, 'Covariance $C(l) = \sigma^{2} - \gamma(l)$', ha='center', va='bottom', color='black') -ax.text(75, 11, 'Variance $\sigma^{2}$', ha='center', va='bottom', color='tab:red') +ax.text(75, variogram_exp(75)-1, 'Semi-variogram $\\gamma(l)$', ha='center', va='top', color='tab:blue') +ax.text(75, 10 - variogram_exp(75) + 1, 'Covariance $C(l) = \\sigma^{2} - \\gamma(l)$', ha='center', va='bottom', color='black') +ax.text(75, 11, 'Variance $\\sigma^{2}$', ha='center', va='bottom', color='tab:red') ax.set_xlim((0, 100)) ax.set_ylim((0, 12)) ax.set_xlabel('Spatial lag $l$') From 20953d94d91c612ca4b05691a885c0aa08deda8f Mon Sep 17 00:00:00 2001 From: rhugonne Date: Wed, 4 Aug 2021 19:37:54 +0200 Subject: [PATCH 102/113] draft standardization gallery example --- examples/plot_nonstationary_error.py | 2 +- examples/plot_standardization.py | 116 +++++++++++++++++++++++++++ examples/plot_vgm_error.py | 2 +- 3 files changed, 118 insertions(+), 2 deletions(-) create mode 100644 examples/plot_standardization.py diff --git a/examples/plot_nonstationary_error.py b/examples/plot_nonstationary_error.py index f8038b0c..8812c081 100644 --- a/examples/plot_nonstationary_error.py +++ b/examples/plot_nonstationary_error.py @@ -195,7 +195,7 @@ # %% # The same function can be used to estimate the spatial distribution of the elevation measurement error over the area: -maxc = np.maximum(profc, planc) +maxc = np.maximum(np.abs(profc), np.abs(planc)) dh_err = slope_curv_to_dh_err((slope, maxc)) plt.figure(figsize=(8, 5)) diff --git a/examples/plot_standardization.py b/examples/plot_standardization.py new file mode 100644 index 00000000..3cb09e48 --- /dev/null +++ b/examples/plot_standardization.py @@ -0,0 +1,116 @@ +""" +Non-stationarity of elevation measurement errors +================================================ + +Digital elevation models have a precision that can vary with terrain and instrument-related variables. However, quantifying +this precision is complex and non-stationarities, i.e. variability of the measurement error, has rarely been +accounted for, with only some studies that used arbitrary filtering thresholds on the slope or other variables (see :ref:`intro`). + +Quantifying the non-stationarities in elevation measurement errors is essential to use stable terrain as a proxy for +assessing the precision on other types of terrain (Hugonnet et al., in prep) and allows to standardize the measurement +errors to reach a stationary variance, an assumption necessary for spatial statistics (see :ref:`spatialstats`). + +Here, we show an example in which we identify terrain-related non-stationarities for a DEM difference of Longyearbyen glacier. +We quantify those non-stationarities by `binning `_ robustly +in N-dimension using :func:`xdem.spatialstats.nd_binning` and applying a N-dimensional interpolation +:func:`xdem.spatialstats.interp_nd_binning` to estimate a numerical function of the measurement error and derive the spatial +distribution of elevation measurement errors of the difference of DEMs. + +**Reference**: `Hugonnet et al. (2021) `_, applied to the terrain slope +and quality of stereo-correlation (Equation 1, Extended Data Fig. 3a). +""" +# sphinx_gallery_thumbnail_number = 8 +import matplotlib.pyplot as plt +import numpy as np +import xdem +import geoutils as gu + +# %% +# We start by estimating the non-stationarities and deriving a terrain-dependent measurement error as shown in +# the :ref:`sphx_glr_auto_examples_plot_nonstationarity_error.py` example. + +# Load the data +ref_dem = xdem.DEM(xdem.examples.get_path("longyearbyen_ref_dem")) +dh = xdem.DEM(xdem.examples.get_path("longyearbyen_ddem")) +glacier_outlines = gu.Vector(xdem.examples.get_path("longyearbyen_glacier_outlines")) +mask_glacier = glacier_outlines.create_mask(dh) + +# Compute the slope and maximum curvature +slope, planc, profc = \ + xdem.terrain.get_terrain_attribute(dem=ref_dem.data, + attribute=['slope', 'planform_curvature', 'profile_curvature'], + resolution=ref_dem.res) + +# We remove values on unstable terrain +dh_arr = dh.data[~mask_glacier] +slope_arr = slope[~mask_glacier] +planc_arr = planc[~mask_glacier] +profc_arr = profc[~mask_glacier] +maxc_arr = np.maximum(np.abs(planc_arr),np.abs(profc_arr)) + +dh_arr[np.abs(dh_arr) > 4 * xdem.spatialstats.nmad(dh_arr)] = np.nan + + +custom_bin_slope = np.unique(np.concatenate([np.quantile(slope_arr,np.linspace(0,0.95,20)), + np.quantile(slope_arr,np.linspace(0.96,0.99,5)), + np.quantile(slope_arr,np.linspace(0.991,1,10))])) + +custom_bin_curvature = np.unique(np.concatenate([np.quantile(maxc_arr,np.linspace(0,0.95,20)), + np.quantile(maxc_arr,np.linspace(0.96,0.99,5)), + np.quantile(maxc_arr,np.linspace(0.991,1,10))])) + +df = xdem.spatialstats.nd_binning(values=dh_arr, list_var=[slope_arr, maxc_arr], list_var_names=['slope', 'maxc'], + statistics=['count', np.nanmedian, np.nanstd], + list_var_bins=[custom_bin_slope,custom_bin_curvature]) + +# Estimate an interpolant of the measurement error with slope and maximum curvature +slope_curv_to_dh_err = xdem.spatialstats.interp_nd_binning(df, list_var_names=['slope', 'maxc'], statistic='nanstd', min_count=30) +maxc = np.maximum(np.abs(profc), np.abs(planc)) + +# Estimated measurement error per pixel +dh_err = slope_curv_to_dh_err((slope, maxc)) + +# %% +# Standardization of the elevation differences +z_dh = dh.data/dh_err +z_dh[mask_glacier] = np.nan +fac_std = np.nanstd(z_dh) +z_dh = z_dh/fac_std + +df_vgm = xdem.spatialstats.sample_empirical_variogram( + values=z_dh.squeeze(), gsd=dh.res[0], subsample=50, runs=30, n_variograms=10, estimator='cressie', random_state=42) + +fun, params = xdem.spatialstats.fit_sum_model_variogram(['Sph', 'Sph'], empirical_variogram=df_vgm) +xdem.spatialstats.plot_vgm(df_vgm, xscale_range_split=[100, 1000, 10000], list_fit_fun=[fun], list_fit_fun_label=['Standardized double-range variogram']) + +# %% +# Let's compute the uncertainty for two glaciers +plog_shp = gu.Vector(glacier_outlines.ds[glacier_outlines.ds['IDENT'] == 13622.100000000000364]) +plog_mask = plog_shp.create_mask(dh) + +southfacing_shp = gu.Vector(glacier_outlines.ds[glacier_outlines.ds['IDENT'] == 13623]) +southfacing_mask = southfacing_shp.create_mask(dh) + +print('Average slope of Plogbreen: {:.1f}'.format(np.nanmean(slope[plog_mask]))) +print('Average maximum curvature of Plogbreen: {:.3f}'.format(np.nanmean(maxc[plog_mask]))) + +print('Average slope of unnamed south-facing glacier: {:.1f}'.format(np.nanmean(slope[southfacing_mask]))) +print('Average maximum curvature of unnamed south-facing glacier : {:.1f}'.format(np.nanmean(maxc[southfacing_mask]))) + +# %% +plog_neff = xdem.spatialstats.neff_circ(plog_shp.ds['Shape_Area'].values[0], [(params[0], 'Sph', params[1]), + (params[2], 'Sph', params[3])]) + +southfacing_neff = xdem.spatialstats.neff_circ(southfacing_shp.ds['Shape_Area'].values[0], [(params[0], 'Sph', params[1]), + (params[2], 'Sph', params[3])]) + +plog_z_err = 1/np.sqrt(plog_neff) +southfacing_z_err = 1/np.sqrt(southfacing_neff) + +# %% +fac_plog_dh_err = fac_std * np.nanmean(dh_err[plog_mask]) +fac_southfacing_dh_err = fac_std * np.nanmean(dh_err[southfacing_mask]) + +# %% +plog_dh_err = fac_plog_dh_err * plog_z_err +southfacing_dh_err = fac_southfacing_dh_err * southfacing_z_err diff --git a/examples/plot_vgm_error.py b/examples/plot_vgm_error.py index 568051d6..b87e99b7 100644 --- a/examples/plot_vgm_error.py +++ b/examples/plot_vgm_error.py @@ -87,7 +87,7 @@ # conveniently by :func:`xdem.spatialstats.sample_empirical_variogram`: df = xdem.spatialstats.sample_empirical_variogram( - values=dh.data, gsd=dh.res[0], subsample=50, runs=30, n_variograms=10, estimator='cressie', random_state=42) + values=dh.data, gsd=dh.res[0], subsample=50, runs=30, n_variograms=10, random_state=42) # %% # *Note: in this example, we add a* ``random_state`` *argument to yield a reproducible random sampling of pixels within From 206d89bb8916e9c4442786d107f517046f53e528 Mon Sep 17 00:00:00 2001 From: rhugonne Date: Thu, 5 Aug 2021 16:23:55 +0200 Subject: [PATCH 103/113] finalize standardization gallery example --- docs/source/intro.rst | 6 +- examples/plot_standardization.py | 184 +++++++++++++++++++++++-------- 2 files changed, 145 insertions(+), 45 deletions(-) diff --git a/docs/source/intro.rst b/docs/source/intro.rst index 02f6e16e..263d7779 100644 --- a/docs/source/intro.rst +++ b/docs/source/intro.rst @@ -92,6 +92,10 @@ statistical measures, to allow accurate DEM uncertainty estimation for everyone. The tools for quantifying DEM precision are described in :ref:`spatialstats`. -.. minigallery:: xdem.spatialstats.sample_empirical_variogram xdem.spatialstats.nd_binning +.. + Functions that are used in several examples create duplicate examples intead of being merged into the list. + Circumventing manually by selecting functions used only once in each example for now. + +.. minigallery:: xdem.spatialstats.neff_circ xdem.spatialstats.plot_1d_binning :add-heading: Examples that use spatial statistics functions diff --git a/examples/plot_standardization.py b/examples/plot_standardization.py index 3cb09e48..17fe3d74 100644 --- a/examples/plot_standardization.py +++ b/examples/plot_standardization.py @@ -1,33 +1,30 @@ """ -Non-stationarity of elevation measurement errors -================================================ +Standardization for stable terrain as proxy +=========================================== -Digital elevation models have a precision that can vary with terrain and instrument-related variables. However, quantifying -this precision is complex and non-stationarities, i.e. variability of the measurement error, has rarely been -accounted for, with only some studies that used arbitrary filtering thresholds on the slope or other variables (see :ref:`intro`). +Digital elevation models have both a precision that can vary with terrain or instrument-related variables, and +a spatial correlation of measurement errors that can be due to effects of resolution, processing or instrument noise. +Accouting for non-stationarities in elevation measurement errors is essential to use stable terrain as a proxy to +infer the precision on other types of terrain (Hugonnet et al., in prep) and reliably use spatial statistics (see +:ref:`spatialstats`). -Quantifying the non-stationarities in elevation measurement errors is essential to use stable terrain as a proxy for -assessing the precision on other types of terrain (Hugonnet et al., in prep) and allows to standardize the measurement -errors to reach a stationary variance, an assumption necessary for spatial statistics (see :ref:`spatialstats`). - -Here, we show an example in which we identify terrain-related non-stationarities for a DEM difference of Longyearbyen glacier. -We quantify those non-stationarities by `binning `_ robustly -in N-dimension using :func:`xdem.spatialstats.nd_binning` and applying a N-dimensional interpolation -:func:`xdem.spatialstats.interp_nd_binning` to estimate a numerical function of the measurement error and derive the spatial -distribution of elevation measurement errors of the difference of DEMs. +Here, we show an example to use standardization of the data based on the terrain-dependent nonstationarity in measurement +error (see :ref:`sphx_glr_auto_examples_plot_nonstationary_error.py`) and combine it with an analysis of spatial +correlation (see :ref:`sphx_glr_auto_examples_plot_vgm_error.py`) to derive spatially integrated errors for specific +spatial ensembles. **Reference**: `Hugonnet et al. (2021) `_, applied to the terrain slope and quality of stereo-correlation (Equation 1, Extended Data Fig. 3a). """ -# sphinx_gallery_thumbnail_number = 8 +# sphinx_gallery_thumbnail_number = 4 import matplotlib.pyplot as plt import numpy as np import xdem import geoutils as gu # %% -# We start by estimating the non-stationarities and deriving a terrain-dependent measurement error as shown in -# the :ref:`sphx_glr_auto_examples_plot_nonstationarity_error.py` example. +# We start by estimating the non-stationarities and deriving a terrain-dependent measurement error as a function of both +# slope and maximum curvature, as shown in the :ref:`sphx_glr_auto_examples_plot_nonstationary_error.py` example. # Load the data ref_dem = xdem.DEM(xdem.examples.get_path("longyearbyen_ref_dem")) @@ -41,16 +38,17 @@ attribute=['slope', 'planform_curvature', 'profile_curvature'], resolution=ref_dem.res) -# We remove values on unstable terrain +# Remove values on unstable terrain dh_arr = dh.data[~mask_glacier] slope_arr = slope[~mask_glacier] planc_arr = planc[~mask_glacier] profc_arr = profc[~mask_glacier] maxc_arr = np.maximum(np.abs(planc_arr),np.abs(profc_arr)) +# Remove large outliers dh_arr[np.abs(dh_arr) > 4 * xdem.spatialstats.nmad(dh_arr)] = np.nan - +# Define bins for 2D binning custom_bin_slope = np.unique(np.concatenate([np.quantile(slope_arr,np.linspace(0,0.95,20)), np.quantile(slope_arr,np.linspace(0.96,0.99,5)), np.quantile(slope_arr,np.linspace(0.991,1,10))])) @@ -59,6 +57,7 @@ np.quantile(maxc_arr,np.linspace(0.96,0.99,5)), np.quantile(maxc_arr,np.linspace(0.991,1,10))])) +# Perform 2D binning to estimate the measurement error with slope and maximum curvature df = xdem.spatialstats.nd_binning(values=dh_arr, list_var=[slope_arr, maxc_arr], list_var_names=['slope', 'maxc'], statistics=['count', np.nanmedian, np.nanstd], list_var_bins=[custom_bin_slope,custom_bin_curvature]) @@ -67,50 +66,147 @@ slope_curv_to_dh_err = xdem.spatialstats.interp_nd_binning(df, list_var_names=['slope', 'maxc'], statistic='nanstd', min_count=30) maxc = np.maximum(np.abs(profc), np.abs(planc)) -# Estimated measurement error per pixel +# Estimate a measurement error per pixel dh_err = slope_curv_to_dh_err((slope, maxc)) # %% -# Standardization of the elevation differences +# Using the measurement error estimated for each pixel, we standardize the elevation differences by applying +# a simple division: + z_dh = dh.data/dh_err -z_dh[mask_glacier] = np.nan -fac_std = np.nanstd(z_dh) + +# %% +# We remove values on glacierized terrain, large outliers, and perform a scale-correction. +z_dh.data[mask_glacier] = np.nan +z_dh.data[np.abs(z_dh.data)>4] = np.nan +fac_std = np.nanstd(z_dh.data) z_dh = z_dh/fac_std +plt.figure(figsize=(8, 5)) +plt_extent = [ + ref_dem.bounds.left, + ref_dem.bounds.right, + ref_dem.bounds.bottom, + ref_dem.bounds.top, +] +ax = plt.gca() +glacier_outlines.ds.plot(ax=ax, fc='none', ec='tab:gray') +ax.plot([], [], color='tab:gray', label='Glacier 1990 outlines') +plt.imshow(z_dh.squeeze(), cmap="RdYlBu", vmin=-3, vmax=3, extent=plt_extent) +cbar = plt.colorbar() +cbar.set_label('Standardized elevation differences (m)') +plt.legend(loc='lower right') +plt.show() + +# %% +# Now, we can perform an analysis of spatial correlation as shown in the :ref:`sphx_glr_auto_examples_plot_vgm_error.py` +# example, by estimating a variogram and fitting a sum of two models. df_vgm = xdem.spatialstats.sample_empirical_variogram( - values=z_dh.squeeze(), gsd=dh.res[0], subsample=50, runs=30, n_variograms=10, estimator='cressie', random_state=42) + values=z_dh.data.squeeze(), gsd=dh.res[0], subsample=50, runs=30, n_variograms=10, random_state=42) fun, params = xdem.spatialstats.fit_sum_model_variogram(['Sph', 'Sph'], empirical_variogram=df_vgm) -xdem.spatialstats.plot_vgm(df_vgm, xscale_range_split=[100, 1000, 10000], list_fit_fun=[fun], list_fit_fun_label=['Standardized double-range variogram']) +xdem.spatialstats.plot_vgm(df_vgm, xscale_range_split=[100, 1000, 10000], list_fit_fun=[fun], + list_fit_fun_label=['Standardized double-range variogram']) # %% -# Let's compute the uncertainty for two glaciers -plog_shp = gu.Vector(glacier_outlines.ds[glacier_outlines.ds['IDENT'] == 13622.100000000000364]) -plog_mask = plog_shp.create_mask(dh) +# With standardized input, the variogram should converge towards one. With the input data close to a stationary +# variance, the variogram will be more robust as it won't be affected by changes in variance due to terrain- or +# instrument-dependent variability of measurement error. The variogram should only capture changes in variance due to +# spatial correlation. -southfacing_shp = gu.Vector(glacier_outlines.ds[glacier_outlines.ds['IDENT'] == 13623]) -southfacing_mask = southfacing_shp.create_mask(dh) - -print('Average slope of Plogbreen: {:.1f}'.format(np.nanmean(slope[plog_mask]))) -print('Average maximum curvature of Plogbreen: {:.3f}'.format(np.nanmean(maxc[plog_mask]))) - -print('Average slope of unnamed south-facing glacier: {:.1f}'.format(np.nanmean(slope[southfacing_mask]))) -print('Average maximum curvature of unnamed south-facing glacier : {:.1f}'.format(np.nanmean(maxc[southfacing_mask]))) +# %% +# **How to use this standardized spatial analysis to compute final uncertainties?** +# +# Let's take the example of two glaciers of similar size: Svendsenbreen and Medalsbreen, which are respectively +# north and south-facing. The south-facing Medalsbreen glacier is subject to more sun exposure, and thus should be +# located in higher slopes, with possibly higher curvatures. + +svendsen_shp = gu.Vector(glacier_outlines.ds[glacier_outlines.ds['NAME'] == 'Svendsenbreen']) +svendsen_mask = svendsen_shp.create_mask(dh) + +medals_shp = gu.Vector(glacier_outlines.ds[glacier_outlines.ds['NAME'] == 'Medalsbreen']) +medals_mask = medals_shp.create_mask(dh) + +plt.figure(figsize=(8, 5)) +ax = plt.gca() +plt_extent = [ + ref_dem.bounds.left, + ref_dem.bounds.right, + ref_dem.bounds.bottom, + ref_dem.bounds.top, +] +plt.imshow(slope.squeeze(), cmap="Blues", vmin=0, vmax=40, extent=plt_extent) +cbar = plt.colorbar(ax=ax) +cbar.set_label('Slope (degrees)') +svendsen_shp.ds.plot(ax=ax, fc='none', ec='tab:olive', lw=2) +medals_shp.ds.plot(ax=ax, fc='none', ec='tab:gray', lw=2) +plt.plot([],[], color='tab:olive', label='Medalsbreen') +plt.plot([], [], color='tab:gray', label='Svendsenbreen') +plt.legend(loc='lower left') +plt.show() + +print('Average slope of Svendsenbreen glacier: {:.1f}'.format(np.nanmean(slope[svendsen_mask]))) +print('Average maximum curvature of Svendsenbreen glacier: {:.3f}'.format(np.nanmean(maxc[svendsen_mask]))) + +print('Average slope of Medalsbreen glacier: {:.1f}'.format(np.nanmean(slope[medals_mask]))) +print('Average maximum curvature of Medalsbreen glacier : {:.1f}'.format(np.nanmean(maxc[medals_mask]))) # %% -plog_neff = xdem.spatialstats.neff_circ(plog_shp.ds['Shape_Area'].values[0], [(params[0], 'Sph', params[1]), +# We calculate the number of effective samples for each glacier based on the variogram +svendsen_neff = xdem.spatialstats.neff_circ(np.sum(svendsen_shp.ds['Shape_Area'].values), [(params[0], 'Sph', params[1]), (params[2], 'Sph', params[3])]) -southfacing_neff = xdem.spatialstats.neff_circ(southfacing_shp.ds['Shape_Area'].values[0], [(params[0], 'Sph', params[1]), +medals_neff = xdem.spatialstats.neff_circ(np.sum(medals_shp.ds['Shape_Area'].values), [(params[0], 'Sph', params[1]), (params[2], 'Sph', params[3])]) -plog_z_err = 1/np.sqrt(plog_neff) -southfacing_z_err = 1/np.sqrt(southfacing_neff) +print('Number of effective samples of Svendsenbreen glacier: {:.1f}'.format(svendsen_neff)) +print('Number of effective samples of Medalsbreen glacier: {:.1f}'.format(medals_neff)) + +# %% +# Due to the long-range spatial correlations affecting the elevation differences, both glacier have a similar, low +# number of effective samples. This transcribes into a large standardized integrated error. + +svendsen_z_err = 1/np.sqrt(svendsen_neff) +medals_z_err = 1/np.sqrt(medals_neff) + +print('Standardized integrated error of Svendsenbreen glacier: {:.1f}'.format(svendsen_z_err)) +print('Standardized integrated error of Medalsbreen glacier: {:.1f}'.format(medals_z_err)) # %% -fac_plog_dh_err = fac_std * np.nanmean(dh_err[plog_mask]) -fac_southfacing_dh_err = fac_std * np.nanmean(dh_err[southfacing_mask]) +# Finally, we destandardize the spatially integrated errors based on the measurement error dependent on slope and +# maximum curvature. This yields the uncertainty into the mean elevation change for each glacier. + +# Destandardize the uncertainty +fac_svendsen_dh_err = fac_std * np.nanmean(dh_err[svendsen_mask]) +fac_medals_dh_err = fac_std * np.nanmean(dh_err[medals_mask]) +svendsen_dh_err = fac_svendsen_dh_err * svendsen_z_err +medals_dh_err = fac_medals_dh_err * medals_z_err + +# Derive mean elevation change +svendsen_dh = np.nanmean(dh.data[svendsen_mask]) +medals_dh = np.nanmean(dh.data[medals_mask]) + +# Plot the result +plt.figure(figsize=(8, 5)) +ax = plt.gca() +plt.imshow(dh.data.squeeze(), cmap="RdYlBu", vmin=-50, vmax=50, extent=plt_extent) +cbar = plt.colorbar(ax=ax) +cbar.set_label('Elevation differences (m)') +svendsen_shp.ds.plot(ax=ax, fc='none', ec='tab:olive', lw=2) +medals_shp.ds.plot(ax=ax, fc='none', ec='tab:gray', lw=2) +plt.plot([],[], color='tab:olive', label='Svendsenbreen glacier') +plt.plot([],[], color='tab:gray', label='Medalsbreen glacier') +ax.text(svendsen_shp.ds.centroid.x.values[0], svendsen_shp.ds.centroid.y.values[0]-1500, + '{:.2f} \n$\\pm$ {:.2f}'.format(svendsen_dh, svendsen_dh_err), color='tab:olive', fontweight='bold', + va='top', ha='center', fontsize=12) +ax.text(medals_shp.ds.centroid.x.values[0], medals_shp.ds.centroid.y.values[0]+2000, + '{:.2f} \n$\\pm$ {:.2f}'.format(medals_dh, medals_dh_err), color='tab:gray', fontweight='bold', + va='bottom', ha='center', fontsize=12) +plt.legend(loc='lower left') +plt.show() # %% -plog_dh_err = fac_plog_dh_err * plog_z_err -southfacing_dh_err = fac_southfacing_dh_err * southfacing_z_err +# Because of slightly higher slopes and curvatures, the final uncertainty for Medalsbreen is larger by about 10%. +# The differences between the mean terrain slope and curvatures of stable terrain and those of glaciers is quite limited +# on Svalbard. In high moutain terrain, such as the Alps or Himalayas, the difference between stable terrain and glaciers, +# and among glaciers, would be much larger. \ No newline at end of file From 0c9edecceb6b9f654b5fec868b58482a9adbb4d4 Mon Sep 17 00:00:00 2001 From: rhugonne Date: Thu, 5 Aug 2021 17:40:50 +0200 Subject: [PATCH 104/113] add standardizing doc plot --- .../source/code/spatialstats_standardizing.py | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 docs/source/code/spatialstats_standardizing.py diff --git a/docs/source/code/spatialstats_standardizing.py b/docs/source/code/spatialstats_standardizing.py new file mode 100644 index 00000000..56ed3ce4 --- /dev/null +++ b/docs/source/code/spatialstats_standardizing.py @@ -0,0 +1,44 @@ +"""Documentation plot illustrating standardization of a distribution""" +import numpy as np +import matplotlib.pyplot as plt + +# Example x vector +mu = 15 +sig = 5 +np.random.seed(42) +y = np.random.normal(mu, sig, size=300) + +fig, ax1 = plt.subplots(figsize=(8,3)) + +# Original histogram +ax1.hist(y, color='tab:blue', edgecolor='white', linewidth=0.5, alpha=0.7) +ax1.vlines(mu, ymin=0, ymax=90, color='tab:blue', linestyle='dashed', lw=2) +ax1.vlines([mu-2*sig, mu+2*sig], ymin=0, ymax=90, colors=['tab:blue','tab:blue'], linestyles='dotted', lw=2) +ax1.annotate('Original\ndata $x$\n$\\mu_{x} = 15$\n$\\sigma_{x} = 5$', xy=(mu+0.5, 85), xytext=(mu+5, 110), arrowprops=dict(color='tab:blue', width=0.5, headwidth=8), + color='tab:blue', fontweight='bold', ha='left') +ax1.spines['right'].set_visible(False) +ax1.spines['top'].set_visible(False) +ax1.spines['left'].set_visible(False) +ax1.set_yticks([]) +ax1.set_ylim((0,130)) + +# Standardized histogram +ax1.hist((y-mu)/sig, color='tab:olive', edgecolor='white', linewidth=0.5, alpha=0.7) +ax1.vlines(0, ymin=0, ymax=90, color='tab:olive', linestyle='dashed', lw=2) +ax1.vlines([-2, 2], ymin=0, ymax=90, colors=['tab:olive','tab:olive'], linestyles='dotted', lw=2) +ax1.annotate('Standardized\ndata $z$\n$\\mu_{z} = 0$\n$\\sigma_{z} = 1$', xy=(-0.3, 85), xytext=(-5, 110), arrowprops=dict(color='tab:olive', width=0.5, headwidth=8), + color='tab:olive', fontweight='bold', ha='left') +ax1.spines['right'].set_visible(False) +ax1.spines['top'].set_visible(False) +ax1.spines['left'].set_visible(False) +ax1.set_yticks([]) +ax1.set_ylim((0,130)) + +ax1.annotate('', xy=(0, 65), xytext=(mu, 65), arrowprops=dict(arrowstyle="-|>", connectionstyle="arc3,rad=0.2", fc="w"), + color='black') +ax1.text(mu/2, 90, 'Standardization:\n$z = \\frac{x - \\mu}{\\sigma}$', color='black', ha='center', fontsize=14, + fontweight='bold') +ax1.plot([], [], color='tab:gray', linestyle='dashed', label='Mean') +ax1.plot([], [], color='tab:gray', linestyle='dotted', label='Standard\ndeviation (2$\\sigma$)') +ax1.legend(loc='center right') + From cfc16f490a0045db9a40238c6dfea85caa3e1227 Mon Sep 17 00:00:00 2001 From: rhugonne Date: Thu, 5 Aug 2021 17:41:13 +0200 Subject: [PATCH 105/113] incremental commit on documentation --- docs/source/code/spatialstats.py | 3 ++ .../spatialstats_nonstationarity_slope.py | 2 +- .../spatialstats_stationarity_assumption.py | 1 + .../code/spatialstats_variogram_covariance.py | 1 + docs/source/spatialstats.rst | 35 ++++++++++++------- 5 files changed, 28 insertions(+), 14 deletions(-) diff --git a/docs/source/code/spatialstats.py b/docs/source/code/spatialstats.py index 0ba9833d..555488d1 100644 --- a/docs/source/code/spatialstats.py +++ b/docs/source/code/spatialstats.py @@ -21,6 +21,9 @@ # Derive a numerical function of the measurement error err_dh = xdem.spatialstats.interp_nd_binning(df_ns, list_var_names=['slope']) +# Standardize the data +z_dh = dh.data / err_dh + # Sample empirical variogram df_vgm = xdem.spatialstats.sample_empirical_variogram(values=dh.data, gsd=dh.res[0], subsample=50, random_state=42, runs=10) diff --git a/docs/source/code/spatialstats_nonstationarity_slope.py b/docs/source/code/spatialstats_nonstationarity_slope.py index b96c9d85..aec5342c 100644 --- a/docs/source/code/spatialstats_nonstationarity_slope.py +++ b/docs/source/code/spatialstats_nonstationarity_slope.py @@ -19,4 +19,4 @@ df_ns = xdem.spatialstats.nd_binning(dh.data.ravel(), list_var=[slope.ravel()], list_var_names=['slope'], statistics=['count', xdem.spatialstats.nmad], list_var_bins=30) -xdem.spatialstats.plot_1d_binning(df_ns, 'slope', 'nmad', 'Slope (degrees)', 'NMAD of dh (m)') \ No newline at end of file +xdem.spatialstats.plot_1d_binning(df_ns, 'slope', 'nmad', 'Slope (degrees)', 'Elevation measurement error (m)') \ No newline at end of file diff --git a/docs/source/code/spatialstats_stationarity_assumption.py b/docs/source/code/spatialstats_stationarity_assumption.py index 50987926..06d72926 100644 --- a/docs/source/code/spatialstats_stationarity_assumption.py +++ b/docs/source/code/spatialstats_stationarity_assumption.py @@ -1,3 +1,4 @@ +"""Documentation plot illustrating stationarity of mean and variance""" import matplotlib.pyplot as plt import numpy as np import xdem diff --git a/docs/source/code/spatialstats_variogram_covariance.py b/docs/source/code/spatialstats_variogram_covariance.py index 3a44afee..b7d76350 100644 --- a/docs/source/code/spatialstats_variogram_covariance.py +++ b/docs/source/code/spatialstats_variogram_covariance.py @@ -1,3 +1,4 @@ +"""Documentation plot illustrating the link between variogram and covariance""" import matplotlib.pyplot as plt import numpy as np diff --git a/docs/source/spatialstats.rst b/docs/source/spatialstats.rst index 438faa72..d0f5b0d8 100644 --- a/docs/source/spatialstats.rst +++ b/docs/source/spatialstats.rst @@ -99,7 +99,7 @@ To estimate the pixel-wise measurement error for elevation data, two issues aris 1. The dispersion :math:`\sigma_{dh}` cannot be estimated directly on changing terrain, 2. The dispersion :math:`\sigma_{dh}` can show important non-stationarities. -In :ref:`spatialstats_nonstationarity`, we describe how to quantify the measurement error as a function of +The section :ref:`spatialstats_nonstationarity` describes how to quantify the measurement error as a function of several explanatory variables by using stable terrain as a proxy. Spatially-integrated elevation measurement error @@ -123,8 +123,8 @@ To estimate the standard error of the mean for elevation data, two issue arises: 1. The dispersion of elevation differences :math:`\sigma_{dh}` is not stationary, a necessary assumption for spatial statistics. 2. The number of pixels in the DEM :math:`N` does not equal the number of independent observations in the DEMs, because of spatial correlations. -In :ref:`spatialstats_corr` and `spatialstats_errorpropag`, we describe how to account for spatial correlations and -use those to estimate a number of effective samples to integrate and propagate measurement errors in space. +The sections :ref:`spatialstats_corr` and :ref:`spatialstats_errorpropag` describe how to account for spatial correlations +and use those to integrate and propagate measurement errors in space. Workflow for DEM precision estimation ------------------------------------- @@ -134,8 +134,7 @@ Workflow for DEM precision estimation Non-stationarity in elevation measurement errors ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -Elevation data contains significant non-stationarities in elevation measurement errors (`Hugonnet -et al. (2021) `_, Hugonnet et al. (in prep)). +Elevation data contains significant non-stationarities in elevation measurement errors. ``xdem`` provides tools to **quantify** these non-stationarities along several explanatory variables, **model** those numerically to estimate an elevation measurement error, and **standardize** them for further analysis. @@ -172,21 +171,33 @@ Once quantified, the non-stationarities can be modelled numerically by linear in variables using :func:`xdem.spatialstats.interp_nd_binning`. .. literalinclude:: code/spatialstats.py - :lines: 20 + :lines: 22 :language: python Standardize elevation differences for further analysis """""""""""""""""""""""""""""""""""""""""""""""""""""" In order to verify the assumptions of spatial statistics and be able to use stable terrain as a reliable proxy in -further analysis (see :ref:`spatialstats_intro`), standardization of the elevation differences are required to -reach a stationary variance. +further analysis (see :ref:`spatialstats_intro`), `standardization `_ +of the elevation differences are required to reach a stationary variance. + +.. plot:: code/spatialstats_standardizing.py + :width: 90% + +For application to DEM precision assessment, the mean is already centered on zero and the variance is non-stationary, +which yields: .. math:: z_{dh} = \frac{dh(\textrm{var}_{1}, \textrm{var}_{2}, \textrm{...})}{\sigma_{dh}(\textrm{var}_{1}, \textrm{var}_{2}, \textrm{...})} where :math:`z_{dh}` is the standardized elevation difference sample. +Code-wise, standardization is as simple as a division with the measurement error ``err_dh`` estimated for each pixel: + +.. literalinclude:: code/spatialstats.py + :lines: 25 + :language: python + To later de-standardize estimations of the dispersion of a given subsample of elevation differences, possibly after further analysis of :ref:`spatialstats_corr` and :ref:`spatialstats_errorpropag`, one simply needs to apply the opposite operation. @@ -207,8 +218,6 @@ average measurement error of the pixels in the subsample, evaluated through the Estimating the standard error of the mean of the standardized data :math:`\sigma_{\overline{z_{dh}}}\vert_{\mathbb{S}}` requires an analysis of spatial correlation and a spatial integration of this correlation, described in the next sections. -TODO: Add a gallery example on the standardization - .. minigallery:: xdem.spatialstats.nd_binning :add-heading: Examples that deal with non-stationarities :heading-level: " @@ -273,7 +282,7 @@ The resulting pairwise differences are evenly distributed across the grid and ac means that lag classes separated by a factor of :math:`\sqrt{2}` have an equal number of pairwise differences computed). .. literalinclude:: code/spatialstats.py - :lines: 25-26 + :lines: 28-29 :language: python The variogram is returned as a ``pd.Dataframe`` object. @@ -297,7 +306,7 @@ This can be performed through the function :func:`xdem.spatialstats.fit_sum_mode ``pd.Dataframe`` variogram. .. literalinclude:: code/spatialstats.py - :lines: 28 + :lines: 31 :language: python .. minigallery:: xdem.spatialstats.sample_empirical_variogram @@ -312,7 +321,7 @@ Spatially integrated measurement errors After quantifying and modelling spatial correlations, those an effective sample size, and elevation measurement error: .. literalinclude:: code/spatialstats.py - :lines: 30-33 + :lines: 34-38 TODO: Add this section based on Rolstad et al. (2009), Hugonnet et al. (in prep) From e2e03b0d03f58b5486bb10244ed701753da93bc4 Mon Sep 17 00:00:00 2001 From: rhugonne Date: Thu, 5 Aug 2021 18:00:57 +0200 Subject: [PATCH 106/113] fix test_docs --- docs/source/code/spatialstats.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/code/spatialstats.py b/docs/source/code/spatialstats.py index 555488d1..b95b7927 100644 --- a/docs/source/code/spatialstats.py +++ b/docs/source/code/spatialstats.py @@ -22,7 +22,7 @@ err_dh = xdem.spatialstats.interp_nd_binning(df_ns, list_var_names=['slope']) # Standardize the data -z_dh = dh.data / err_dh +z_dh = dh.data.ravel() / err_dh(slope.ravel()) # Sample empirical variogram df_vgm = xdem.spatialstats.sample_empirical_variogram(values=dh.data, gsd=dh.res[0], subsample=50, From 0919dfd47d87691e7b691b2371bdf41f404e68c5 Mon Sep 17 00:00:00 2001 From: rhugonne Date: Fri, 6 Aug 2021 10:06:18 +0200 Subject: [PATCH 107/113] incremental commit on documentation --- docs/source/robuststats.rst | 4 ++-- docs/source/spatialstats.rst | 11 ++++++----- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/docs/source/robuststats.rst b/docs/source/robuststats.rst index 8023e749..55c147a8 100644 --- a/docs/source/robuststats.rst +++ b/docs/source/robuststats.rst @@ -19,8 +19,8 @@ to first resort to outlier filtering (see :ref:`filters`) and perform analysis u .. _robuststats_meanstd: -Measures of central tendency and dispersion of a sample --------------------------------------------------------- +Measures of central tendency and dispersion +------------------------------------------- Central tendency ^^^^^^^^^^^^^^^^ diff --git a/docs/source/spatialstats.rst b/docs/source/spatialstats.rst index d0f5b0d8..4a64d92a 100644 --- a/docs/source/spatialstats.rst +++ b/docs/source/spatialstats.rst @@ -44,13 +44,13 @@ That is, if the three following assumptions are verified: In other words, for a reliable analysis, the DEM should: 1. Not contain systematic biases that do not average out over sufficiently large distances (e.g., shifts, tilts), but can contain pseudo-periodic biases (e.g., along-track undulations), - 2. Not contain measurement errors that vary significantly in space. - 3. Not contain factors that significantly affect the distribution of measurement errors, except for the spatial distance. + 2. Not contain measurement errors that vary significantly across space. + 3. Not contain factors that affect the spatial distribution of measurement errors, except for the distance between observations. Quantifying the precision of a single DEM, or of a difference of DEMs ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -To statistically infer the precision of a DEM, the DEM has to be compared against independent elevation observations. +To statistically infer the precision of a DEM, it is compared against independent elevation observations. Significant measurement errors can originate from both sets of elevation observations, and the analysis of differences will represent the mixed precision of the two. As there is no reason for a dependency between the elevation data sets, the analysis of elevation differences yields: @@ -184,7 +184,7 @@ of the elevation differences are required to reach a stationary variance. .. plot:: code/spatialstats_standardizing.py :width: 90% -For application to DEM precision assessment, the mean is already centered on zero and the variance is non-stationary, +For application to DEM precision estimation, the mean is already centered on zero and the variance is non-stationary, which yields: .. math:: @@ -192,7 +192,8 @@ which yields: where :math:`z_{dh}` is the standardized elevation difference sample. -Code-wise, standardization is as simple as a division with the measurement error ``err_dh`` estimated for each pixel: +Code-wise, standardization is as simple as a division of the elevation differences ``dh`` using the estimated measurement +error: .. literalinclude:: code/spatialstats.py :lines: 25 From 0638e391c4ba5a49dd4fedc5feba6d812cdbcba4 Mon Sep 17 00:00:00 2001 From: rhugonne Date: Wed, 11 Aug 2021 21:51:47 +0200 Subject: [PATCH 108/113] raise warning and return nan dataframe when no valid patch is found --- xdem/spatialstats.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/xdem/spatialstats.py b/xdem/spatialstats.py index 2f313d79..a2d2c33b 100644 --- a/xdem/spatialstats.py +++ b/xdem/spatialstats.py @@ -1238,7 +1238,13 @@ def patches_method(values: np.ndarray, gsd: float, area: float, mask: Optional[n # Remove cadrants already sampled from list list_cadrant = [c for j, c in enumerate(list_cadrant) if j not in list_idx_cadrant] - df_all = pd.concat(list_df) + if len(list_df)>0: + df_all = pd.concat(list_df) + else: + warnings.warn('No valid patch found covering this area: returning dataframe containing only nodata' ) + df_all = pd.DataFrame() + for j, statistic in enumerate(statistics): + df_all[statistics_name[j]] = [np.nan] return df_all From 55b0ae3e9d0733a5bc791d4aa892d4ffcf3003ec Mon Sep 17 00:00:00 2001 From: rhugonne Date: Wed, 11 Aug 2021 21:54:42 +0200 Subject: [PATCH 109/113] nan is clearly than nodata --- xdem/spatialstats.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/xdem/spatialstats.py b/xdem/spatialstats.py index a2d2c33b..84f1eaa4 100644 --- a/xdem/spatialstats.py +++ b/xdem/spatialstats.py @@ -1241,7 +1241,7 @@ def patches_method(values: np.ndarray, gsd: float, area: float, mask: Optional[n if len(list_df)>0: df_all = pd.concat(list_df) else: - warnings.warn('No valid patch found covering this area: returning dataframe containing only nodata' ) + warnings.warn('No valid patch found covering this area: returning dataframe containing only nans' ) df_all = pd.DataFrame() for j, statistic in enumerate(statistics): df_all[statistics_name[j]] = [np.nan] From 8a4037c7972f39392877d43bcb97de6c9fb23a9f Mon Sep 17 00:00:00 2001 From: rhugonne Date: Wed, 11 Aug 2021 21:59:06 +0200 Subject: [PATCH 110/113] accelerate patch sampling for large nan matrices --- xdem/spatialstats.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/xdem/spatialstats.py b/xdem/spatialstats.py index 84f1eaa4..8fc801c8 100644 --- a/xdem/spatialstats.py +++ b/xdem/spatialstats.py @@ -1201,6 +1201,9 @@ def patches_method(values: np.ndarray, gsd: float, area: float, mask: Optional[n i = list_cadrant[idx_cadrant][0] j = list_cadrant[idx_cadrant][1] + if not np.isfinite(values[i, j]): + continue + if patch_shape == 'rectangular': patch = values[nx_sub * i:nx_sub * (i + 1), ny_sub * j:ny_sub * (j + 1)].flatten() elif patch_shape == 'circular': From 261579f0aee532e41fc7b3133fdfbbe245b16825 Mon Sep 17 00:00:00 2001 From: rhugonne Date: Fri, 13 Aug 2021 15:47:55 +0200 Subject: [PATCH 111/113] update minimal skgstat version --- dev-environment.yml | 2 +- environment.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dev-environment.yml b/dev-environment.yml index 68f51ae7..39468a65 100644 --- a/dev-environment.yml +++ b/dev-environment.yml @@ -14,7 +14,7 @@ dependencies: - tqdm - scikit-image - proj-data - - scikit-gstat + - scikit-gstat>=0.6.8 - pytransform3d - geoutils diff --git a/environment.yml b/environment.yml index fd870b6d..942d82c6 100644 --- a/environment.yml +++ b/environment.yml @@ -14,6 +14,6 @@ dependencies: - tqdm - scikit-image - proj-data - - scikit-gstat + - scikit-gstat>=0.6.8 - pytransform3d - geoutils From a73872b40511270d16f4eb45ae16e35eccb929a8 Mon Sep 17 00:00:00 2001 From: rhugonne Date: Fri, 13 Aug 2021 15:58:30 +0200 Subject: [PATCH 112/113] try previous skgstat version for CI --- dev-environment.yml | 2 +- environment.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dev-environment.yml b/dev-environment.yml index 39468a65..7b2fe6ba 100644 --- a/dev-environment.yml +++ b/dev-environment.yml @@ -14,7 +14,7 @@ dependencies: - tqdm - scikit-image - proj-data - - scikit-gstat>=0.6.8 + - scikit-gstat>=0.6.7 - pytransform3d - geoutils diff --git a/environment.yml b/environment.yml index 942d82c6..45018baf 100644 --- a/environment.yml +++ b/environment.yml @@ -14,6 +14,6 @@ dependencies: - tqdm - scikit-image - proj-data - - scikit-gstat>=0.6.8 + - scikit-gstat>=0.6.7 - pytransform3d - geoutils From e2b994a367b5860613010d518eee8b0324e8286a Mon Sep 17 00:00:00 2001 From: rhugonne Date: Fri, 13 Aug 2021 16:28:28 +0200 Subject: [PATCH 113/113] eriks comments on gallery examples --- examples/plot_nonstationary_error.py | 4 ++-- examples/plot_standardization.py | 15 ++++++++++----- examples/plot_vgm_error.py | 6 +++--- 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/examples/plot_nonstationary_error.py b/examples/plot_nonstationary_error.py index 8812c081..5ec20ecb 100644 --- a/examples/plot_nonstationary_error.py +++ b/examples/plot_nonstationary_error.py @@ -10,7 +10,7 @@ assessing the precision on other types of terrain (Hugonnet et al., in prep) and allows to standardize the measurement errors to reach a stationary variance, an assumption necessary for spatial statistics (see :ref:`spatialstats`). -Here, we show an example in which we identify terrain-related non-stationarities for a DEM difference of Longyearbyen glacier. +Here, we show an example in which we identify terrain-related non-stationarities for a DEM difference at Longyearbyen. We quantify those non-stationarities by `binning `_ robustly in N-dimension using :func:`xdem.spatialstats.nd_binning` and applying a N-dimensional interpolation :func:`xdem.spatialstats.interp_nd_binning` to estimate a numerical function of the measurement error and derive the spatial @@ -26,7 +26,7 @@ import geoutils as gu # %% -# We start by loading example files including a difference of DEMs at Longyearbyen glacier, the reference DEM later used to derive +# We start by loading example files including a difference of DEMs at Longyearbyen, the reference DEM later used to derive # several terrain attributes, and the outlines to rasterize a glacier mask. # Prior to differencing, the DEMs were aligned using :ref:`coregistration_nuthkaab` as shown in # the :ref:`sphx_glr_auto_examples_plot_nuth_kaab.py` example. We later refer to those elevation differences as *dh*. diff --git a/examples/plot_standardization.py b/examples/plot_standardization.py index 17fe3d74..475eb650 100644 --- a/examples/plot_standardization.py +++ b/examples/plot_standardization.py @@ -76,11 +76,16 @@ z_dh = dh.data/dh_err # %% -# We remove values on glacierized terrain, large outliers, and perform a scale-correction. +# We remove values on glacierized terrain and large outliers. z_dh.data[mask_glacier] = np.nan z_dh.data[np.abs(z_dh.data)>4] = np.nan -fac_std = np.nanstd(z_dh.data) -z_dh = z_dh/fac_std + +# %% +# We perform a scale-correction for the standardization, to ensure that the standard deviation of the data is exactly 1. +print('Standard deviation before scale-correction: {:.1f}'.format(np.nanstd(z_dh.data))) +scale_fac_std = np.nanstd(z_dh.data) +z_dh = z_dh/scale_fac_std +print('Standard deviation after scale-correction: {:.1f}'.format(np.nanstd(z_dh.data))) plt.figure(figsize=(8, 5)) plt_extent = [ @@ -177,8 +182,8 @@ # maximum curvature. This yields the uncertainty into the mean elevation change for each glacier. # Destandardize the uncertainty -fac_svendsen_dh_err = fac_std * np.nanmean(dh_err[svendsen_mask]) -fac_medals_dh_err = fac_std * np.nanmean(dh_err[medals_mask]) +fac_svendsen_dh_err = scale_fac_std * np.nanmean(dh_err[svendsen_mask]) +fac_medals_dh_err = scale_fac_std * np.nanmean(dh_err[medals_mask]) svendsen_dh_err = fac_svendsen_dh_err * svendsen_z_err medals_dh_err = fac_medals_dh_err * medals_z_err diff --git a/examples/plot_vgm_error.py b/examples/plot_vgm_error.py index b87e99b7..ad1dcbb5 100644 --- a/examples/plot_vgm_error.py +++ b/examples/plot_vgm_error.py @@ -16,8 +16,8 @@ several methods exist to derive the related measurement error integrated in space (`Rolstad et al. (2009) `_ , Hugonnet et al. (in prep)). More details are available in :ref:`spatialstats`. -Here, we show an example in which we estimate spatially integrated elevation measurement errors for a DEM difference of -Longyearbyen glacier, demonstrated in :ref:`sphx_glr_auto_examples_plot_nuth_kaab.py`. We first quantify the spatial +Here, we show an example in which we estimate spatially integrated elevation measurement errors for a DEM difference at +Longyearbyen, demonstrated in :ref:`sphx_glr_auto_examples_plot_nuth_kaab.py`. We first quantify the spatial correlations using :func:`xdem.spatialstats.sample_empirical_variogram` based on routines of `scikit-gstat `_. We then model the empirical variogram using a sum of variogram models using :func:`xdem.spatialstats.fit_sum_model_variogram`. @@ -33,7 +33,7 @@ import geoutils as gu # %% -# We start by loading example files including a difference of DEMs at Longyearbyen glacier and the outlines to rasterize +# We start by loading example files including a difference of DEMs at Longyearbyen and the outlines to rasterize # a glacier mask. # Prior to differencing, the DEMs were aligned using :ref:`coregistration_nuthkaab` as shown in # the :ref:`sphx_glr_auto_examples_plot_nuth_kaab.py` example. We later refer to those elevation differences as *dh*.