Repository: scipy/scipy

Issue:
stats.distributions does not have trapezoidal distribution
The continuous distributions in `scipy.stats` do not contain the trapezoidal distribution. We have two limiting cases, `uniform` and `triang`, but not the parent.



Discussion:
There also seems to be a generalised trapezoidal distribution:

http://www.inside-r.org/packages/cran/trapezoid/docs/Trapezoid


Resolving patch:
diff --git a/scipy/stats/__init__.py b/scipy/stats/__init__.py
--- a/scipy/stats/__init__.py
+++ b/scipy/stats/__init__.py
@@ -103,6 +103,7 @@
    semicircular      -- Semicircular
    skewnorm          -- Skew normal
    t                 -- Student's T
+   trapz              -- Trapezoidal
    triang            -- Triangular
    truncexpon        -- Truncated Exponential
    truncnorm         -- Truncated Normal
diff --git a/scipy/stats/_continuous_distns.py b/scipy/stats/_continuous_distns.py
--- a/scipy/stats/_continuous_distns.py
+++ b/scipy/stats/_continuous_distns.py
@@ -4225,6 +4225,56 @@ def _stats(self, a, moments='mvsk'):
 
 skewnorm = skew_norm_gen(name='skewnorm')
 
+
+class trapz_gen(rv_continuous):
+    """A trapezoidal continuous random variable.
+
+    %(before_notes)s
+
+    Notes
+    -----
+    The trapezoidal distribution can be represented with an up-sloping line
+    from ``loc`` to ``(loc + c*scale)``, then constant to ``(loc + d*scale)``
+    and then downsloping from ``(loc + d*scale)`` to ``(loc+scale)``.
+
+    `trapz` takes ``c`` and ``d`` as shape parameters.
+
+    %(after_notes)s
+
+    The standard form is in the range [0, 1] with c the mode.
+    The location parameter shifts the start to `loc`.
+    The scale parameter changes the width from 1 to `scale`.
+
+    %(example)s
+
+    """
+    def _argcheck(self, c, d):
+        return (c >= 0) & (c <= 1) & (d >= 0) & (d <= 1) & (d >= c)
+
+    def _pdf(self, x, c, d):
+        u = 2 / (d - c + 1)
+
+        condlist = [x < c, x <= d, x > d]
+        choicelist = [u * x / c, u, u * (1 - x) / (1 - d)]
+        return np.select(condlist, choicelist)
+
+    def _cdf(self, x, c, d):
+        condlist = [x < c, x <= d, x > d]
+        choicelist = [x**2 / c / (d - c + 1),
+                      (c + 2 * (x - c)) / (d - c + 1),
+                      1 - ((1 - x)**2 / (d - c + 1) / (1 - d))]
+        return np.select(condlist, choicelist)
+
+    def _ppf(self, q, c, d):
+        qc, qd = self._cdf(c, c, d), self._cdf(d, c, d)
+        condlist = [q < qc, q <= qd, q > qd]
+        choicelist = [np.sqrt(q * c * (1 + d - c)),
+                      0.5 * q * (1 + d - c) + 0.5 * c,
+                      1 - sqrt((1 - q) * (d - c + 1) * (1 - d))]
+        return np.select(condlist, choicelist)
+trapz = trapz_gen(a=0.0, b=1.0, name="trapz")
+
+
 class triang_gen(rv_continuous):
     """A triangular continuous random variable.
 
diff --git a/scipy/stats/_distr_params.py b/scipy/stats/_distr_params.py
--- a/scipy/stats/_distr_params.py
+++ b/scipy/stats/_distr_params.py
@@ -87,6 +87,7 @@
     ['semicircular', ()],
     ['skewnorm', (4.0,)],
     ['t', (2.7433514990818093,)],
+    ['trapz', (0.2, 0.8)],
     ['triang', (0.15785029824528218,)],
     ['truncexpon', (4.6907725456810478,)],
     ['truncnorm', (-1.0978730080013919, 2.7306754109031979)],


Test patch:
diff --git a/scipy/stats/tests/test_distributions.py b/scipy/stats/tests/test_distributions.py
--- a/scipy/stats/tests/test_distributions.py
+++ b/scipy/stats/tests/test_distributions.py
@@ -40,7 +40,7 @@
          'halflogistic', 'fatiguelife', 'foldnorm', 'ncx2', 't', 'nct',
          'weibull_min', 'weibull_max', 'dweibull', 'maxwell', 'rayleigh',
          'genlogistic', 'logistic', 'gumbel_l', 'gumbel_r', 'gompertz',
-         'hypsecant', 'laplace', 'reciprocal', 'triang', 'tukeylambda',
+         'hypsecant', 'laplace', 'reciprocal', 'trapz', 'triang', 'tukeylambda',
          'vonmises', 'vonmises_line', 'pearson3', 'gennorm', 'halfgennorm',
          'rice']
 
@@ -75,7 +75,9 @@ def test_all_distributions():
         if dist == 'fatiguelife':
             alpha = 0.001
 
-        if dist == 'triang':
+        if dist == 'trapz':
+            args = tuple(np.sort(np.random.random(nargs)))
+        elif dist == 'triang':
             args = tuple(np.random.random(nargs))
         elif dist == 'reciprocal':
             vals = np.random.random(nargs)
@@ -1865,6 +1867,72 @@ def test_rdist_cdf_gh1285(self):
                             values, decimal=5)
 
 
+class TestTrapz(TestCase):
+    def test_reduces_to_triang(self):
+        modes = [0.3, 0.5]
+        for mode in modes:
+            x = [0, mode, 1]
+            assert_almost_equal(stats.trapz.pdf(x, mode, mode),
+                                stats.triang.pdf(x, mode))
+            assert_almost_equal(stats.trapz.cdf(x, mode, mode),
+                                stats.triang.cdf(x, mode))
+
+    def test_reduces_to_uniform(self):
+            x = np.linspace(0, 1, 10)
+
+            old_err = np.seterr(divide='ignore')
+
+            with warnings.catch_warnings():
+                warnings.simplefilter('ignore', RuntimeWarning)
+                assert_almost_equal(stats.trapz.pdf(x, 0, 1),
+                                    stats.uniform.pdf(x))
+                assert_almost_equal(stats.trapz.cdf(x, 0, 1),
+                                    stats.uniform.cdf(x))
+
+            np.seterr(**old_err)
+
+    def test_cases(self):
+            old_err = np.seterr(divide='ignore')
+
+            with warnings.catch_warnings():
+                warnings.simplefilter('ignore', RuntimeWarning)
+
+                # edge cases
+                assert_almost_equal(stats.trapz.pdf(0, 0, 0), 2)
+                assert_almost_equal(stats.trapz.pdf(1, 1, 1), 2)
+                assert_almost_equal(stats.trapz.pdf(0.5, 0, 0.8), 1.11111111111111111)
+                assert_almost_equal(stats.trapz.pdf(0.5, 0.2, 1.0), 1.11111111111111111)
+
+                # straightforward case
+                assert_almost_equal(stats.trapz.pdf(0.1, 0.2, 0.8), 0.625)
+                assert_almost_equal(stats.trapz.pdf(0.5, 0.2, 0.8), 1.25)
+                assert_almost_equal(stats.trapz.pdf(0.9, 0.2, 0.8), 0.625)
+
+                assert_almost_equal(stats.trapz.cdf(0.1, 0.2, 0.8), 0.03125)
+                assert_almost_equal(stats.trapz.cdf(0.2, 0.2, 0.8), 0.125)
+                assert_almost_equal(stats.trapz.cdf(0.5, 0.2, 0.8), 0.5)
+                assert_almost_equal(stats.trapz.cdf(0.9, 0.2, 0.8), 0.96875)
+                assert_almost_equal(stats.trapz.cdf(1.0, 0.2, 0.8), 1.0)
+
+            np.seterr(**old_err)
+
+    def test_trapz_vect(self):
+        # test that array-valued shapes and arguments are handled
+        c = np.array([0.1, 0.2, 0.3])
+        d = np.array([0.5, 0.6])[:, None]
+        x = np.array([0.15, 0.25, 0.9])
+        v = stats.trapz.pdf(x, c, d)
+
+        cc, dd, xx = np.broadcast_arrays(c, d, x)
+
+        res = np.empty(xx.size, dtype=xx.dtype)
+        ind = np.arange(xx.size)
+        for i, x1, c1, d1 in zip(ind, xx.ravel(), cc.ravel(), dd.ravel()):
+            res[i] = stats.trapz.pdf(x1, c1, d1)
+
+        assert_allclose(v, res.reshape(v.shape), atol=1e-15)
+
+
 def test_540_567():
     # test for nan returned in tickets 540, 567
     assert_almost_equal(stats.norm.cdf(-1.7624320982), 0.03899815971089126,
diff --git a/scipy/stats/tests/test_fit.py b/scipy/stats/tests/test_fit.py
--- a/scipy/stats/tests/test_fit.py
+++ b/scipy/stats/tests/test_fit.py
@@ -33,7 +33,8 @@
         'tukeylambda',
         'vonmises',
         'wrapcauchy',
-        'levy_stable'
+        'levy_stable',
+        'trapz'
 ]
 
 # Don't run the fit test on these: