Repository: scipy/scipy

Issue:
ENH: Add Negative Hypergeometric Distribution
Part of ["A Solid Foundation for Statistics in Python with SciPy"](https://chanzuckerberg.com/eoss/proposals/a-solid-foundation-for-statistics-in-python-with-scipy/).

Add [Negative Hypergeometric Distribution](https://en.wikipedia.org/wiki/Negative_hypergeometric_distribution) to `scipy.stats`.

See gh-9309 for example of adding a discrete distribution. See methods inherited from `rv_discrete`; override when a formula is available. Note in PR what you could not override; perhaps we can help.

Consider using [this](https://en.wikipedia.org/wiki/Negative_hypergeometric_distribution) for reference and to generate test cases.

@fletcheaston @WarrenWeckesser 


Discussion:
Also a requirement to read is http://scipy.github.io/devdocs/dev/contributor/adding_new.html#adding-a-new-statistics-distribution
Thanks, Paul. Now I remember seeing that while going through the developer docs.

Resolving patch:
diff --git a/scipy/stats/__init__.py b/scipy/stats/__init__.py
--- a/scipy/stats/__init__.py
+++ b/scipy/stats/__init__.py
@@ -157,6 +157,7 @@
    hypergeom         -- Hypergeometric
    logser            -- Logarithmic (Log-Series, Series)
    nbinom            -- Negative Binomial
+   nhypergeom        -- Negative Hypergeometric
    planck            -- Planck (Discrete Exponential)
    poisson           -- Poisson
    randint           -- Discrete Uniform
diff --git a/scipy/stats/_discrete_distns.py b/scipy/stats/_discrete_distns.py
--- a/scipy/stats/_discrete_distns.py
+++ b/scipy/stats/_discrete_distns.py
@@ -36,6 +36,10 @@ class binom_gen(rv_discrete):
 
     %(example)s
 
+    See Also
+    --------
+    hypergeom, nbinom, nhypergeom
+
     """
     def _rvs(self, n, p, size=None, random_state=None):
         return random_state.binomial(n, p, size)
@@ -256,6 +260,10 @@ class nbinom_gen(rv_discrete):
 
     %(example)s
 
+    See Also
+    --------
+    hypergeom, binom, nhypergeom
+
     """
     def _rvs(self, n, p, size=None, random_state=None):
         return random_state.negative_binomial(n, p, size)
@@ -426,6 +434,10 @@ class hypergeom_gen(rv_discrete):
 
     >>> R = hypergeom.rvs(M, n, N, size=10)
 
+    See Also
+    --------
+    nhypergeom, binom, nbinom
+
     """
     def _rvs(self, M, n, N, size=None, random_state=None):
         return random_state.hypergeometric(n, M-n, N, size=size)
@@ -514,6 +526,144 @@ def _logcdf(self, k, M, n, N):
 hypergeom = hypergeom_gen(name='hypergeom')
 
 
+class nhypergeom_gen(rv_discrete):
+    r"""A negative hypergeometric discrete random variable.
+
+    Consider a box containing :math:`M` balls:, :math:`n` red and
+    :math:`M-n` blue. We randomly sample balls from the box, one
+    at a time and *without* replacement, until we have picked :math:`r`
+    blue balls. `nhypergeom` is the distribution of the number of
+    red balls :math:`k` we have picked.
+
+    %(before_notes)s
+
+    Notes
+    -----
+    The symbols used to denote the shape parameters (`M`, `n`, and `r`) are not
+    universally accepted. See the Examples for a clarification of the
+    definitions used here.
+
+    The probability mass function is defined as,
+
+    .. math:: f(k; M, n, r) = \frac{{{k+r-1}\choose{k}}{{M-r-k}\choose{n-k}}}
+                                   {{M \choose n}}
+
+    for :math:`k \in [0, n]`, :math:`n \in [0, M]`, :math:`r \in [0, M-n]`,
+    and the binomial coefficient is:
+
+    .. math:: \binom{n}{k} \equiv \frac{n!}{k! (n - k)!}.
+
+    It is equivalent to observing :math:`k` successes in :math:`k+r-1`
+    samples with :math:`k+r`'th sample being a failure. The former
+    can be modelled as a hypergeometric distribution. The probability
+    of the latter is simply the number of failures remaining
+    :math:`M-n-(r-1)` divided by the size of the remaining population
+    :math:`M-(k+r-1)`. This relationship can be shown as:
+
+    .. math:: NHG(k;M,n,r) = HG(k;M,n,k+r-1)\frac{(M-n-(r-1))}{(M-(k+r-1))}
+
+    where :math:`NHG` is probability mass function (PMF) of the
+    negative hypergeometric distribution and :math:`HG` is the
+    PMF of the hypergeometric distribution.
+
+    %(after_notes)s
+
+    Examples
+    --------
+    >>> from scipy.stats import nhypergeom
+    >>> import matplotlib.pyplot as plt
+
+    Suppose we have a collection of 20 animals, of which 7 are dogs.
+    Then if we want to know the probability of finding a given number
+    of dogs (successes) in a sample with exactly 12 animals that
+    aren't dogs (failures), we can initialize a frozen distribution
+    and plot the probability mass function:
+
+    >>> M, n, r = [20, 7, 12]
+    >>> rv = nhypergeom(M, n, r)
+    >>> x = np.arange(0, n+2)
+    >>> pmf_dogs = rv.pmf(x)
+
+    >>> fig = plt.figure()
+    >>> ax = fig.add_subplot(111)
+    >>> ax.plot(x, pmf_dogs, 'bo')
+    >>> ax.vlines(x, 0, pmf_dogs, lw=2)
+    >>> ax.set_xlabel('# of dogs in our group with given 12 failures')
+    >>> ax.set_ylabel('nhypergeom PMF')
+    >>> plt.show()
+
+    Instead of using a frozen distribution we can also use `nhypergeom`
+    methods directly.  To for example obtain the probability mass
+    function, use:
+
+    >>> prb = nhypergeom.pmf(x, M, n, r)
+
+    And to generate random numbers:
+
+    >>> R = nhypergeom.rvs(M, n, r, size=10)
+
+    To verify the relationship between `hypergeom` and `nhypergeom`, use:
+
+    >>> from scipy.stats import hypergeom, nhypergeom
+    >>> M, n, r = 45, 13, 8
+    >>> k = 6
+    >>> nhypergeom.pmf(k, M, n, r)
+    0.06180776620271643
+    >>> hypergeom.pmf(k, M, n, k+r-1) * (M - n - (r-1)) / (M - (k+r-1))
+    0.06180776620271644
+
+    See Also
+    --------
+    hypergeom, binom, nbinom
+
+    References
+    ----------
+    .. [1] Negative Hypergeometric Distribution on Wikipedia
+           https://en.wikipedia.org/wiki/Negative_hypergeometric_distribution
+
+    .. [2] Negative Hypergeometric Distribution from
+           http://www.math.wm.edu/~leemis/chart/UDR/PDFs/Negativehypergeometric.pdf
+
+    """
+    def _get_support(self, M, n, r):
+        return 0, n
+
+    def _argcheck(self, M, n, r):
+        cond = (n >= 0) & (n <= M) & (r >= 0) & (r <= M-n)
+        return cond
+
+    def _logpmf(self, k, M, n, r):
+        cond = ((r == 0) & (k == 0))
+        result = _lazywhere(~cond, (k, M, n, r),
+                            lambda k, M, n, r:
+                                (-betaln(k+1, r) + betaln(k+r, 1) -
+                                 betaln(n-k+1, M-r-n+1) + betaln(M-r-k+1, 1) +
+                                 betaln(n+1, M-n+1) - betaln(M+1, 1)),
+                            fillvalue=0.0)
+        return result
+
+    def _pmf(self, k, M, n, r):
+        # same as the following but numerically more precise
+        # return comb(k+r-1, k) * comb(M-r-k, n-k) / comb(M, n)
+        return exp(self._logpmf(k, M, n, r))
+
+    def _stats(self, M, n, r):
+        # Promote the datatype to at least float
+        # mu = rn / (M-n+1)
+        M, n, r = 1.*M, 1.*n, 1.*r
+        mu = r*n / (M-n+1)
+
+        var = r*(M+1)*n / ((M-n+1)*(M-n+2)) * (1 - r / (M-n+1))
+
+        # The skew and kurtosis are mathematically
+        # intractable so return `None`. See [2]_.
+        g1, g2 = None, None
+        return mu, var, g1, g2
+
+
+nhypergeom = nhypergeom_gen(name='nhypergeom')
+
+
 # FIXME: Fails _cdfvec
 class logser_gen(rv_discrete):
     r"""A Logarithmic (Log-Series, Series) discrete 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
@@ -128,5 +128,6 @@
     ['randint', (7, 31)],
     ['skellam', (15, 8)],
     ['zipf', (6.5,)],
-    ['yulesimon',(11.0,)]
+    ['yulesimon', (11.0,)],
+    ['nhypergeom', (20, 7, 1)]
 ]


Test patch:
diff --git a/scipy/stats/tests/test_discrete_distns.py b/scipy/stats/tests/test_discrete_distns.py
--- a/scipy/stats/tests/test_discrete_distns.py
+++ b/scipy/stats/tests/test_discrete_distns.py
@@ -1,4 +1,4 @@
-from scipy.stats import betabinom, hypergeom, bernoulli, boltzmann
+from scipy.stats import betabinom, hypergeom, nhypergeom, bernoulli, boltzmann
 import numpy as np
 from numpy.testing import assert_almost_equal, assert_equal, assert_allclose
 
@@ -29,6 +29,36 @@ def test_hypergeom_logpmf():
     assert_almost_equal(hypergeom_logpmf, bernoulli_logpmf, decimal=12)
 
 
+def test_nhypergeom_pmf():
+    # test with hypergeom
+    M, n, r = 45, 13, 8
+    k = 6
+    NHG = nhypergeom.pmf(k, M, n, r)
+    HG = hypergeom.pmf(k, M, n, k+r-1) * (M - n - (r-1)) / (M - (k+r-1))
+    assert_allclose(HG, NHG, rtol=1e-10)
+
+
+def test_nhypergeom_pmfcdf():
+    # test pmf and cdf with arbitrary values.
+    M = 8
+    n = 3
+    r = 4
+    support = np.arange(n+1)
+    pmf = nhypergeom.pmf(support, M, n, r)
+    cdf = nhypergeom.cdf(support, M, n, r)
+    assert_allclose(pmf, [1/14, 3/14, 5/14, 5/14], rtol=1e-13)
+    assert_allclose(cdf, [1/14, 4/14, 9/14, 1.0], rtol=1e-13)
+
+
+def test_nhypergeom_r0():
+    # test with `r = 0`.
+    M = 10
+    n = 3
+    r = 0
+    pmf = nhypergeom.pmf([[0, 1, 2, 0], [1, 2, 0, 3]], M, n, r)
+    assert_allclose(pmf, [[1, 0, 0, 1], [0, 0, 1, 0]], rtol=1e-13)
+
+
 def test_boltzmann_upper_bound():
     k = np.arange(-3, 5)