Repository: statsmodels/statsmodels (Python)

Issue:
ENH/BUG iqr is not scaled for normal distribution
https://www.statsmodels.org/stable/_modules/statsmodels/tools/eval_measures.html#iqr

computes raw IQR, I thought we have scaling to normal distribution as in `robust.scale.mad`
(iqr is now also available in scipy)

code search finds adjustment or usage like `iqr = (q75 - q25) / 1.349`

I never remember: are mad and iqr scale for variance or standard deviation (sqrt or not)

and there is a bug in axis handling !



Hints:
I think we can add a by default normalized iqr based on numpy percentiles in `robust.scale` (possibly with option for other distributions.

Patch:
diff --git a/statsmodels/robust/scale.py b/statsmodels/robust/scale.py
--- a/statsmodels/robust/scale.py
+++ b/statsmodels/robust/scale.py
@@ -49,6 +49,44 @@ def mad(a, c=Gaussian.ppf(3/4.), axis=0, center=np.median):
     return np.median((np.abs(a-center)) / c, axis=axis)
 
 
+def iqr(a, c=Gaussian.ppf(3/4) - Gaussian.ppf(1/4), axis=0, center=np.median):
+    """
+    The normalized interquartile range along given axis of an array
+
+    Parameters
+    ----------
+    a : array_like
+        Input array.
+    c : float, optional
+        The normalization constant, used to get consistent estimates of the
+        standard deviation at the normal distribution.  Defined as
+        scipy.stats.norm.ppf(3/4.) - scipy.stats.norm.ppf(1/4.), which is
+        approximately 1.349.
+    axis : int, optional
+        The default is 0. Can also be None.
+    center : callable or float
+        If a callable is provided, such as the default `np.median` then it
+        is expected to be called center(a). The axis argument will be applied
+        via np.apply_over_axes. Otherwise, provide a float.
+
+    Returns
+    -------
+    The normalized interquartile range
+    """
+    a = array_like(a, 'a', ndim=None)
+    c = float_like(c, 'c')
+
+    if a.size == 0:
+        return np.nan
+    else:
+        if callable(center) and a.size:
+            center = np.apply_over_axes(center, a, axis)
+        else:
+            center = 0.0
+        quantiles = np.quantile(a - center, [0.25, 0.75], axis=axis)
+        return np.squeeze(np.diff(quantiles, axis=0) / c)
+
+
 class Huber(object):
     """
     Huber's proposal 2 for estimating location and scale jointly.


Test patch:
diff --git a/statsmodels/robust/tests/test_scale.py b/statsmodels/robust/tests/test_scale.py
--- a/statsmodels/robust/tests/test_scale.py
+++ b/statsmodels/robust/tests/test_scale.py
@@ -31,6 +31,9 @@ def test_median(self):
     def test_mad(self):
         assert_almost_equal(scale.mad(self.chem), 0.52632, DECIMAL)
 
+    def test_iqr(self):
+        assert_almost_equal(scale.iqr(self.chem), 0.68570, DECIMAL)
+
     def test_huber_scale(self):
         assert_almost_equal(scale.huber(self.chem)[0], 3.20549, DECIMAL)
 
@@ -98,6 +101,52 @@ def test_axisneg1(self):
         assert_equal(m.shape, (40, 10))
 
 
+class TestIqr(object):
+    @classmethod
+    def setup_class(cls):
+        np.random.seed(54321)
+        cls.X = standard_normal((40, 10))
+
+    def test_iqr(self):
+        m = scale.iqr(self.X)
+        assert_equal(m.shape, (10,))
+
+    def test_iqr_empty(self):
+        empty = np.empty(0)
+        assert np.isnan(scale.iqr(empty))
+        empty = np.empty((10, 100, 0))
+        assert_equal(scale.iqr(empty, axis=1), np.empty((10, 0)))
+        empty = np.empty((100, 100, 0, 0))
+        assert_equal(scale.iqr(empty, axis=-1), np.empty((100, 100, 0)))
+
+    def test_iqr_center(self):
+        n = scale.iqr(self.X, center=0)
+        assert_equal(n.shape, (10,))
+
+
+class TestIqrAxes(object):
+    @classmethod
+    def setup_class(cls):
+        np.random.seed(54321)
+        cls.X = standard_normal((40, 10, 30))
+
+    def test_axis0(self):
+        m = scale.iqr(self.X, axis=0)
+        assert_equal(m.shape, (10, 30))
+
+    def test_axis1(self):
+        m = scale.iqr(self.X, axis=1)
+        assert_equal(m.shape, (40, 30))
+
+    def test_axis2(self):
+        m = scale.iqr(self.X, axis=2)
+        assert_equal(m.shape, (40, 10))
+
+    def test_axisneg1(self):
+        m = scale.iqr(self.X, axis=-1)
+        assert_equal(m.shape, (40, 10))
+
+
 class TestHuber(object):
     @classmethod
     def setup_class(cls):