Repository: nolanbconaway/binoculars

Issue:
Cloper Pearson?
For completeness and elegance it would be interesting to also have this method:

https://en.wikipedia.org/wiki/Binomial_proportion_confidence_interval#Clopper%E2%80%93Pearson_interval

which, if I understood correctly is the most conservative.

What do you think?

Comments:

Patch:
diff --git a/lib/binoculars/__init__.py b/lib/binoculars/__init__.py
index ab04d17..7a4885d 100644
--- a/lib/binoculars/__init__.py
+++ b/lib/binoculars/__init__.py
@@ -7,7 +7,7 @@ from scipy import stats
 
 def binomial_jeffreys_interval(p: float, n: int, tail: str, z: float = 1.96):
     """Use compute a jeffrey's interval via beta distirbution CDF."""
-    alpha = stats.norm.sf(z)
+    alpha = stats.norm.sf(z) * 2
     a = n * p + 0.5
     b = n - n * p + 0.5
     if tail == "lower":
@@ -41,6 +41,18 @@ def binomial_normal_interval(p: float, n: int, tail: str, z: float = 1.96):
         raise ValueError("Invalid tail! Choose from: lower, upper")
 
 
+def binomial_clopper_pearson_interval(p: float, n: int, tail: str, z: float = 1.96):
+    """Return the clopper-pearson interval for a proportion."""
+    alpha = stats.norm.sf(z) * 2
+    k = p * n
+    if tail == "lower":
+        return stats.beta.ppf(alpha / 2, k, n - k + 1)
+    elif tail == "upper":
+        return stats.beta.ppf(1 - alpha / 2, k + 1, n - k)
+    else:
+        raise ValueError("Invalid tail! Choose from: lower, upper")
+
+
 def binomial_confidence(
     p: float, n: int, tail: str = None, z: float = 1.96, method="jeffrey"
 ) -> Union[float, Tuple[float]]:
@@ -53,13 +65,14 @@ def binomial_confidence(
         n : int
             The n parameter of the binomial for the distributionon,
         tail : str
-            Tail of the CI to return, either lower or upper. If not provided, this function returns
-            a tuple of (lower, upper). if provided, it returns a float value.
+            Tail of the CI to return, either lower or upper. If not provided, this
+            function returns a tuple of (lower, upper). if provided, it returns a float
+            value.
         z : float
             Optional Z critical value. Default 1.96 for 95%.
         method : str
-            Optional approximation method. By default this uses Jeffrey's interval. Options:
-            jeffrey, wilson, normal.
+            Optional approximation method. By default this uses Jeffrey's interval.
+            Options: jeffrey, wilson, normal, clopper-pearson.
 
     Returns
         A tuple of (lower, upper) confidence interval values, or a single value.
@@ -69,10 +82,13 @@ def binomial_confidence(
             "jeffrey": binomial_jeffreys_interval,
             "wilson": binomial_wilson_interval,
             "normal": binomial_normal_interval,
+            "clopper-pearson": binomial_clopper_pearson_interval,
         }[method]
 
     except KeyError:
-        raise ValueError("Invalid method! Choose from: jeffrey, wilson, normal")
+        raise ValueError(
+            "Invalid method! Choose from: jeffrey, wilson, normal, clopper-pearson"
+        )
 
     if tail is not None:
         return func(p=p, n=n, z=z, tail=tail)
diff --git a/readme.md b/readme.md
index 2f37517..76827e6 100644
--- a/readme.md
+++ b/readme.md
@@ -14,6 +14,7 @@ Presently, the package implements:
 - [The Normal Approximation](https://en.wikipedia.org/wiki/Binomial_proportion_confidence_interval#Normal_approximation_interval)
 - [The Wilson Interval](https://en.wikipedia.org/wiki/Binomial_proportion_confidence_interval#Wilson_score_interval) (no continuity correction)
 - [Jeffrey's interval](https://en.wikipedia.org/wiki/Binomial_proportion_confidence_interval#Jeffreys_interval) (via scipy.stats.beta)
+- [Clopper-Pearson interval](https://en.wikipedia.org/wiki/Binomial_proportion_confidence_interval#Clopper%E2%80%93Pearson_interval) (also via scipy.stats.beta)
 
 If you haven't spent a lot of time thinking about which interval _you_ should use (and why would you want to?), I suggest using the Wilson interval or Jeffrey's interval. Jeffrey's interval is returned by default by the `binomial_confidence` function in this package. 
 
diff --git a/setup.py b/setup.py
index 070b04b..359819e 100644
--- a/setup.py
+++ b/setup.py
@@ -15,7 +15,7 @@ LONG_DESCRIPTION = (THIS_DIRECTORY / "readme.md").read_text()
 
 setup(
     name="binoculars",
-    version="0.1.2",
+    version="0.1.3",
     description="Various calculations for binomial confidence intervals.",
     long_description=LONG_DESCRIPTION,
     long_description_content_type="text/markdown",
@@ -35,5 +35,4 @@ setup(
     extras_require=dict(
         test=["black==20.8b1", "pytest==6.2.1", "pytest-cov==2.10.1", "codecov==2.1.11"]
     ),
-    package_data={"shabadoo": ["version"]},
 )


Test patch:
diff --git a/.github/workflows/test_on_push.yml b/.github/workflows/test_on_push.yml
index e3e403f..3569dce 100644
--- a/.github/workflows/test_on_push.yml
+++ b/.github/workflows/test_on_push.yml
@@ -7,7 +7,7 @@ jobs:
     runs-on: ubuntu-latest
     strategy:
       matrix:
-        python-version: [3.6, 3.7, 3.8]
+        python-version: [3.6, 3.7, 3.8, 3.9]
 
     steps:
       - uses: actions/checkout@master
diff --git a/test_binoculars.py b/test_binoculars.py
index 3300fac..efe4166 100644
--- a/test_binoculars.py
+++ b/test_binoculars.py
@@ -3,8 +3,10 @@ import pytest
 
 import binoculars
 
+METHODS = ["jeffrey", "wilson", "normal", "clopper-pearson"]
 
-@pytest.mark.parametrize("method", ["jeffrey", "wilson", "normal"])
+
+@pytest.mark.parametrize("method", METHODS)
 @pytest.mark.parametrize("n", [5, 1e2, 1e3, 1e5, 1e8])
 @pytest.mark.parametrize("p", [0.01, 0.5, 0.99])
 def test_lower_less_upper(method, n, p):
@@ -21,7 +23,7 @@ def test_lower_less_upper(method, n, p):
     assert u == binoculars.binomial_confidence(p, n, method=method, tail="upper")
 
 
-@pytest.mark.parametrize("method", ["jeffrey", "wilson", "normal"])
+@pytest.mark.parametrize("method", METHODS)
 @pytest.mark.parametrize("lower_n, greater_n", [(2, 3), (10, 20), (100, 200)])
 def test_more_certain_with_n(method, lower_n, greater_n):
     """Test that certainty diminishes with greater N."""
@@ -32,7 +34,7 @@ def test_more_certain_with_n(method, lower_n, greater_n):
     assert lower_u > greater_u
 
 
-@pytest.mark.parametrize("method", ["jeffrey", "wilson", "normal"])
+@pytest.mark.parametrize("method", METHODS)
 @pytest.mark.parametrize("lower_z, greater_z", [(1, 1.01), (1.96, 2.58)])
 def test_z_certainty(method, lower_z, greater_z):
     """Test that the interval tightens with lower Z"""
@@ -45,7 +47,7 @@ def test_z_certainty(method, lower_z, greater_z):
     assert lower_u < greater_u
 
 
-@pytest.mark.parametrize("method", ["jeffrey", "wilson", "normal"])
+@pytest.mark.parametrize("method", METHODS)
 def test_invalid_tail_error(method):
     with pytest.raises(ValueError):
         binoculars.binomial_confidence(0.1, 10, tail="NOPE", method=method)