Repository: scipy/scipy

Issue:
Geometric standard deviation
Small issue, but I wonder if it would make sense to add a geometric standard deviation function (e.g.
 ``scipy.stats.gstd()``) to go along with the geometric mean function (``scipy.stats.gmean()``).  I'd be willing to make a PR if there's interest.


Discussion:
I haven't used geometric mean / std, but it makes sense to have gstd as an additional to gmean in my view.
@kalekundert Do you still want to add this? If not I'll take a look at it over the next few ~~days~~ weeks.
Go for it; I clearly haven't made any progress in the last few months. :-)

Resolving patch:
diff --git a/scipy/stats/__init__.py b/scipy/stats/__init__.py
--- a/scipy/stats/__init__.py
+++ b/scipy/stats/__init__.py
@@ -189,6 +189,7 @@
    variation         -- Coefficient of variation
    find_repeats
    trim_mean
+   gstd              -- Geometric Standard Deviation
    iqr
    sem
    bayes_mvs
diff --git a/scipy/stats/stats.py b/scipy/stats/stats.py
--- a/scipy/stats/stats.py
+++ b/scipy/stats/stats.py
@@ -82,6 +82,7 @@
     sem
     zmap
     zscore
+    gstd
     iqr
     median_absolute_deviation
 
@@ -184,7 +185,7 @@
            'normaltest', 'jarque_bera', 'itemfreq',
            'scoreatpercentile', 'percentileofscore',
            'cumfreq', 'relfreq', 'obrientransform',
-           'sem', 'zmap', 'zscore', 'iqr', 'median_absolute_deviation',
+           'sem', 'zmap', 'zscore', 'iqr', 'gstd', 'median_absolute_deviation',
            'sigmaclip', 'trimboth', 'trim1', 'trim_mean', 'f_oneway',
            'pearsonr', 'fisher_exact', 'spearmanr', 'pointbiserialr',
            'kendalltau', 'weightedtau',
@@ -2356,6 +2357,127 @@ def zmap(scores, compare, axis=0, ddof=0):
         return (scores - mns) / sstd
 
 
+def gstd(a, axis=0, ddof=1):
+    """Calculate the geometric standard deviation of an array
+
+    The geometric standard deviation describes the spread of a set of numbers
+    where the geometric mean is preferred. It is a multiplicative factor, and
+    so a dimensionless quantity.
+
+    It is defined as the exponent of the standard deviation of ``log(a)``.
+    Mathematically the population geometric standard deviation can be
+    evaluated as::
+
+        gstd = exp(std(log(a)))
+
+    .. versionadded:: 1.3.0
+
+    Parameters
+    ----------
+    a : array_like
+        An array like object containing the sample data.
+    axis : int, tuple or None, optional
+        Axis along which to operate. Default is 0. If None, compute over
+        the whole array `a`.
+    ddof : int, optional
+        Degree of freedom correction in the calculation of the
+        geometric standard deviation. Default is 1.
+
+    Returns
+    -------
+    ndarray or float
+        An array of the geometric standard deviation. If `axis` is None or `a`
+        is a 1d array a float is returned.
+
+    Notes
+    -----
+    As the calculation requires the use of logarithms the geometric standard
+    deviation only supports strictly positive values. Any non-positive or
+    infinite values will raise a `ValueError`.
+    The geometric standard deviation is sometimes confused with the exponent of
+    the standard deviation, ``exp(std(a))``. Instead the geometric standard
+    deviation is ``exp(std(log(a)))``.
+    The default value for `ddof` is different to the default value (0) used
+    by other ddof containing functions, such as ``np.std`` and ``np.nanstd``.
+
+    Examples
+    --------
+    Find the geometric standard deviation of a log-normally distributed sample.
+    Note that the standard deviation of the distribution is one, on a
+    log scale this evaluates to approximately ``exp(1)``.
+
+    >>> from scipy.stats import gstd
+    >>> np.random.seed(123)
+    >>> sample = np.random.lognormal(mean=0, sigma=1, size=1000)
+    >>> gstd(sample)
+    2.7217860664589946
+
+    Compute the geometric standard deviation of a multidimensional array and
+    of a given axis.
+
+    >>> a = np.arange(1, 25).reshape(2, 3, 4)
+    >>> gstd(a, axis=None)
+    2.2944076136018947
+    >>> gstd(a, axis=2)
+    array([[1.82424757, 1.22436866, 1.13183117],
+           [1.09348306, 1.07244798, 1.05914985]])
+    >>> gstd(a, axis=(1,2))
+    array([2.12939215, 1.22120169])
+
+    The geometric standard deviation further handles masked arrays.
+
+    >>> a = np.arange(1, 25).reshape(2, 3, 4)
+    >>> ma = np.ma.masked_where(a > 16, a)
+    >>> ma
+    masked_array(
+      data=[[[1, 2, 3, 4],
+             [5, 6, 7, 8],
+             [9, 10, 11, 12]],
+            [[13, 14, 15, 16],
+             [--, --, --, --],
+             [--, --, --, --]]],
+      mask=[[[False, False, False, False],
+             [False, False, False, False],
+             [False, False, False, False]],
+            [[False, False, False, False],
+             [ True,  True,  True,  True],
+             [ True,  True,  True,  True]]],
+      fill_value=999999)
+    >>> gstd(ma, axis=2)
+    masked_array(
+      data=[[1.8242475707663655, 1.2243686572447428, 1.1318311657788478],
+            [1.0934830582350938, --, --]],
+      mask=[[False, False, False],
+            [False,  True,  True]],
+      fill_value=999999)
+    """
+    a = np.asanyarray(a)
+    log = ma.log if isinstance(a, ma.MaskedArray) else np.log
+
+    try:
+        with warnings.catch_warnings():
+            warnings.simplefilter("error", RuntimeWarning)
+            return np.exp(np.std(log(a), axis=axis, ddof=ddof))
+    except RuntimeWarning as w:
+        if np.isinf(a).any():
+            raise ValueError(
+                'Infinite value encountered. The geometric standard deviation '
+                'is defined for strictly positive values only.')
+        elif np.less_equal(a, 0).any():
+            raise ValueError(
+                'Non positive value encountered. The geometric standard '
+                'deviation is defined for strictly positive values only.')
+        elif 'Degrees of freedom <= 0 for slice' == str(w):
+            raise ValueError(w)
+        else:
+            #  Remaining warnings don't need to be exceptions.
+            warnings.warn(w)
+    except TypeError:
+        raise ValueError(
+            'Invalid array input. The inputs could not be '
+            'safely coerced to any supported types')
+
+
 # Private dictionary initialized only once at module level
 # See https://en.wikipedia.org/wiki/Robust_measures_of_scale
 _scale_conversions = {'raw': 1.0,


Test patch:
diff --git a/scipy/stats/tests/test_stats.py b/scipy/stats/tests/test_stats.py
--- a/scipy/stats/tests/test_stats.py
+++ b/scipy/stats/tests/test_stats.py
@@ -3778,6 +3778,87 @@ def test_1d_array0(self):
             np.seterr(**olderr)
 
 
+class TestGeometricStandardDeviation(object):
+    # must add 1 as `gstd` is only defined for positive values
+    array_1d = np.arange(2 * 3 * 4) + 1
+    gstd_array_1d = 2.294407613602
+    array_3d = array_1d.reshape(2, 3, 4)
+
+    def test_1d_array(self):
+        gstd_actual = stats.gstd(self.array_1d)
+        assert_allclose(gstd_actual, self.gstd_array_1d)
+
+    def test_1d_numeric_array_like_input(self):
+        gstd_actual = stats.gstd(tuple(self.array_1d))
+        assert_allclose(gstd_actual, self.gstd_array_1d)
+
+    def test_raises_value_error_non_array_like_input(self):
+        with pytest.raises(ValueError, match='Invalid array input'):
+            stats.gstd('This should fail as it can not be cast to an array.')
+
+    def test_raises_value_error_zero_entry(self):
+        with pytest.raises(ValueError, match='Non positive value'):
+            stats.gstd(np.append(self.array_1d, [0]))
+
+    def test_raises_value_error_negative_entry(self):
+        with pytest.raises(ValueError, match='Non positive value'):
+            stats.gstd(np.append(self.array_1d, [-1]))
+
+    def test_raises_value_error_inf_entry(self):
+        with pytest.raises(ValueError, match='Infinite value'):
+            stats.gstd(np.append(self.array_1d, [np.inf]))
+
+    def test_propogates_nan_values(self):
+        a = array([[1, 1, 1, 16], [np.nan, 1, 2, 3]])
+        gstd_actual = stats.gstd(a, axis=1)
+        assert_allclose(gstd_actual, np.array([4, np.nan]))
+
+    def test_ddof_equal_to_number_of_observations(self):
+        with pytest.raises(ValueError, match='Degrees of freedom <= 0'):
+            stats.gstd(self.array_1d, ddof=self.array_1d.size)
+
+    def test_3d_array(self):
+        gstd_actual = stats.gstd(self.array_3d, axis=None)
+        assert_allclose(gstd_actual, self.gstd_array_1d)
+
+    def test_3d_array_axis_type_tuple(self):
+        gstd_actual = stats.gstd(self.array_3d, axis=(1,2))
+        assert_allclose(gstd_actual, [2.12939215, 1.22120169])
+
+    def test_3d_array_axis_0(self):
+        gstd_actual = stats.gstd(self.array_3d, axis=0)
+        gstd_desired = np.array([
+            [6.1330555493918, 3.958900210120, 3.1206598248344, 2.6651441426902],
+            [2.3758135028411, 2.174581428192, 2.0260062829505, 1.9115518327308],
+            [1.8205343606803, 1.746342404566, 1.6846557065742, 1.6325269194382]
+        ])
+        assert_allclose(gstd_actual, gstd_desired)
+
+    def test_3d_array_axis_1(self):
+        gstd_actual = stats.gstd(self.array_3d, axis=1)
+        gstd_desired = np.array([
+            [3.118993630946, 2.275985934063, 1.933995977619, 1.742896469724],
+            [1.271693593916, 1.254158641801, 1.238774141609, 1.225164057869]
+        ])
+        assert_allclose(gstd_actual, gstd_desired)
+
+    def test_3d_array_axis_2(self):
+        gstd_actual = stats.gstd(self.array_3d, axis=2)
+        gstd_desired = np.array([
+            [1.8242475707664, 1.2243686572447, 1.1318311657788],
+            [1.0934830582351, 1.0724479791887, 1.0591498540749]
+        ])
+        assert_allclose(gstd_actual, gstd_desired)
+
+    def test_masked_3d_array(self):
+        ma = np.ma.masked_where(self.array_3d > 16, self.array_3d)
+        gstd_actual = stats.gstd(ma, axis=2)
+        gstd_desired = stats.gstd(self.array_3d, axis=2)
+        mask = [[0, 0, 0], [0, 1, 1]]
+        assert_allclose(gstd_actual, gstd_desired)
+        assert_equal(gstd_actual.mask, mask)
+
+
 def test_binomtest():
     # precision tests compared to R for ticket:986
     pp = np.concatenate((np.linspace(0.1,0.2,5), np.linspace(0.45,0.65,5),