Repository: scipy/scipy

Issue:
Implement beta binomial distribution
The beta binomial distribution is a generalized version of the binomial in which the proportion parameter is sampled from a beta distribution.  This has wide applications in bioinformatics and computational biology where events appear to be binomial in nature but the observed variance is greater than can be inferred from a binomial model alone.  For my own use, I have written a [temporary module](https://gist.github.com/PikalaxALT/6e2a39ce689764b5c53a97c0a6aa7e18) which defines a `betabinomgen` object from a scipy discrete distribution.  However, the cdf and sf functions are imprecise since they use fixed-width bins; and the ppf is a brute force approach.  An alternative would be to invoke the appropriate hypergeometric function as suggested by Wikipedia, however this particular hypergeometric function does not appear to be implemented currently.

Related: Beta negative binomial


Discussion:
👍

Is there a road map on how to proceed? 
Perhaps a good benchmark is the [R implementation](https://rdrr.io/rforge/TailRank/src/R/beta-binomial.R)?

Thanks for providing your implementation!
Could you provide a license for that gist? 
Would you be interested in packaging that code for installation with pip while this issue is still open? I can help with that.
+1
As you note the Wikipedia page https://en.wikipedia.org/wiki/Beta-binomial_distribution uses the `3F2(a, b, k)` hypergeometric function for the CDF/SF, and `3F2()` is not in SciPy. The gist compensates for this by making 10,000 calls to the CDF of the Binomial distribution `bdtr()`, and 10,000 calls to the CDF of the beta distribution `btdtr()`.  That might be computationally expensive.  
Do you have any comparisons of this approach with other approaches?  Are 10,000 evaluations always needed to ensure sufficient accuracy?
There's another [R implementation](https://github.com/swihart/rmutil/blob/master/R/dist.r)
You should not read R implementations, covered by GPL, if you are
writing a replacement with difference license.

I have translated some code of mine into a python module - mystats.py. The python is doubtless execrable but the calculations for cdf and sf for BetaBinomial and BetaNegativeBinomial are good. 
[mystatsBetaBinomialpy.txt](https://github.com/scipy/scipy/files/2425447/mystatsBetaBinomialpy.txt)

Happy to leave you to play with it and answer any questions you may have.

Resolving patch:
diff --git a/scipy/stats/__init__.py b/scipy/stats/__init__.py
--- a/scipy/stats/__init__.py
+++ b/scipy/stats/__init__.py
@@ -147,6 +147,7 @@
    :toctree: generated/
 
    bernoulli         -- Bernoulli
+   betabinom         -- Beta-Binomial
    binom             -- Binomial
    boltzmann         -- Boltzmann (Truncated Discrete Exponential)
    dlaplace          -- Discrete Laplacian
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
@@ -152,6 +152,87 @@ def _entropy(self, p):
 bernoulli = bernoulli_gen(b=1, name='bernoulli')
 
 
+class betabinom_gen(rv_discrete):
+    r"""A beta-binomial discrete random variable.
+
+    %(before_notes)s
+
+    Notes
+    -----
+    The beta-binomial distribution is a binomial distribution with a
+    probability of success `p` that follows a beta distribution.
+
+    The probability mass function for `betabinom` is:
+
+    .. math::
+
+       f(k) = \binom{n}{k} \frac{B(k + a, n - k + b)}{B(a, b)}
+
+    for ``k`` in ``{0, 1,..., n}``, :math:`n \geq 0`, :math:`a > 0`,
+    :math:`b > 0`, where :math:`B(a, b)` is the beta function.
+
+    `betabinom` takes :math:`n`, :math:`a`, and :math:`b` as shape parameters.
+
+    References
+    ----------
+    .. [1] https://en.wikipedia.org/wiki/Beta-binomial_distribution
+
+    %(after_notes)s
+
+    .. versionadded:: 1.4.0
+
+    See Also
+    --------
+    beta, binom
+
+    %(example)s
+
+    """
+
+    def _rvs(self, n, a, b):
+        p = self._random_state.beta(a, b, self._size)
+        return self._random_state.binomial(n, p, self._size)
+
+    def _get_support(self, n, a, b):
+        return 0, n
+
+    def _argcheck(self, n, a, b):
+        return (n >= 0) & (a > 0) & (b > 0)
+
+    def _logpmf(self, x, n, a, b):
+        k = floor(x)
+        combiln = -log(n + 1) - betaln(n - k + 1, k + 1)
+        return combiln + betaln(k + a, n - k + b) - betaln(a, b)
+
+    def _pmf(self, x, n, a, b):
+        return exp(self._logpmf(x, n, a, b))
+
+    def _stats(self, n, a, b, moments='mv'):
+        e_p = a / (a + b)
+        e_q = 1 - e_p
+        mu = n * e_p
+        var = n * (a + b + n) * e_p * e_q / (a + b + 1)
+        g1, g2 = None, None
+        if 's' in moments:
+            g1 = 1.0 / sqrt(var)
+            g1 *= (a + b + 2 * n) * (b - a)
+            g1 /= (a + b + 2) * (a + b)
+        if 'k' in moments:
+            g2 = a + b
+            g2 *= (a + b - 1 + 6 * n)
+            g2 += 3 * a * b * (n - 2)
+            g2 += 6 * n ** 2
+            g2 -= 3 * e_p * b * n * (6 - n)
+            g2 -= 18 * e_p * e_q * n ** 2
+            g2 *= (a + b) ** 2 * (1 + a + b)
+            g2 /= (n * a * b * (a + b + 2) * (a + b + 3) * (a + b + n))
+            g2 -= 3
+        return mu, var, g1, g2
+
+
+betabinom = betabinom_gen(name='betabinom')
+
+
 class nbinom_gen(rv_discrete):
     r"""A negative binomial 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
@@ -111,6 +111,7 @@
 
 distdiscrete = [
     ['bernoulli',(0.3,)],
+    ['betabinom', (5, 2.3, 0.63)],
     ['binom', (5, 0.4)],
     ['boltzmann',(1.4, 19)],
     ['dlaplace', (0.8,)],  # 0.5


Test patch:
diff --git a/scipy/stats/tests/test_discrete_basic.py b/scipy/stats/tests/test_discrete_basic.py
--- a/scipy/stats/tests/test_discrete_basic.py
+++ b/scipy/stats/tests/test_discrete_basic.py
@@ -100,7 +100,7 @@ def test_rvs_broadcast(dist, shape_args):
     # implementation detail of the distribution, not a requirement.  If
     # the implementation the rvs() method of a distribution changes, this
     # test might also have to be changed.
-    shape_only = dist in ['skellam', 'yulesimon']
+    shape_only = dist in ['betabinom', 'skellam', 'yulesimon']
 
     try:
         distfunc = getattr(stats, dist)
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,6 +1,6 @@
 from __future__ import division, print_function, absolute_import
 
-from scipy.stats import hypergeom, bernoulli, boltzmann
+from scipy.stats import betabinom, hypergeom, bernoulli, boltzmann
 import numpy as np
 from numpy.testing import assert_almost_equal, assert_equal, assert_allclose
 
@@ -48,3 +48,23 @@ def test_boltzmann_upper_bound():
     c = boltzmann.cdf(k, lam, N)
     expected = [0, 0, 0, 4/7, 6/7, 1, 1, 1]
     assert_allclose(c, expected, rtol=1e-13)
+
+
+def test_betabinom_a_and_b_unity():
+    # test limiting case that betabinom(n, 1, 1) is a discrete uniform
+    # distribution from 0 to n
+    n = 20
+    k = np.arange(n + 1)
+    p = betabinom(n, 1, 1).pmf(k)
+    expected = np.repeat(1 / (n + 1), n + 1)
+    assert_almost_equal(p, expected)
+
+
+def test_betabinom_bernoulli():
+    # test limiting case that betabinom(1, a, b) = bernoulli(a / (a + b))
+    a = 2.3
+    b = 0.63
+    k = np.arange(2)
+    p = betabinom(1, a, b).pmf(k)
+    expected = bernoulli(a / (a + b)).pmf(k)
+    assert_almost_equal(p, expected)