Repository: pints-team/pints

Issue:
Add reciprocal distribution prior (LogUniformLogPrior) to help estimate parameters that vary over multiple orders of magnitude
Hi, thanks for a great package! It would be great to have a  [reciprocal/log uniform](https://en.wikipedia.org/wiki/Reciprocal_distribution) prior. It should be straightforward since it's implemented in `scipy.stats`, and I would be happy to implement it.

Comments:

Patch:
diff --git a/README.md b/README.md
index 9c8b3bf0..69b30e83 100644
--- a/README.md
+++ b/README.md
@@ -76,8 +76,11 @@ To see what's changed in the latest release, see the [CHANGELOG](https://github.
 
 ## Contributing to PINTS
 
-If you'd like to help us develop PINTS by adding new methods, writing documentation, or fixing embarassing bugs, please have a look at these [guidelines](https://github.com/pints-team/pints/blob/main/CONTRIBUTING.md) first.
+There are lots of ways to contribute to PINTS development, and anyone is free to join in!
+For example, you can report problems or make feature requests on the [issues](https://github.com/pints-team/pints/issues) pages.
 
+Similarly, if you want to contribute documentation or code you can tell us your idea on this page, and then provide a pull request for review.
+Because PINTS is a big project, we've written extensive [contribution guidelines](https://github.com/pints-team/pints/blob/master/CONTRIBUTING.md) to help standardise the code -- but don't worry, this will become clear during review.
 
 ## License
 
diff --git a/docs/source/log_priors.rst b/docs/source/log_priors.rst
index 03a5291c..65c1bb0c 100644
--- a/docs/source/log_priors.rst
+++ b/docs/source/log_priors.rst
@@ -23,6 +23,7 @@ Overview:
 - :class:`HalfCauchyLogPrior`
 - :class:`InverseGammaLogPrior`
 - :class:`LogNormalLogPrior`
+- :class:`LogUniformLogPrior`
 - :class:`MultivariateGaussianLogPrior`
 - :class:`NormalLogPrior`
 - :class:`StudentTLogPrior`
@@ -48,6 +49,8 @@ Overview:
 
 .. autoclass:: LogNormalLogPrior
 
+.. autoclass:: LogUniformLogPrior
+
 .. autoclass:: MultivariateGaussianLogPrior
 
 .. autoclass:: NormalLogPrior
diff --git a/pints/__init__.py b/pints/__init__.py
index e6448e64..1c1591d5 100644
--- a/pints/__init__.py
+++ b/pints/__init__.py
@@ -100,6 +100,7 @@ from ._log_priors import (
     HalfCauchyLogPrior,
     InverseGammaLogPrior,
     LogNormalLogPrior,
+    LogUniformLogPrior,
     MultivariateGaussianLogPrior,
     NormalLogPrior,
     StudentTLogPrior,
diff --git a/pints/_log_priors.py b/pints/_log_priors.py
index eb669ab9..77edbac6 100644
--- a/pints/_log_priors.py
+++ b/pints/_log_priors.py
@@ -748,6 +748,74 @@ class LogNormalLogPrior(pints.LogPrior):
                                        s=self._scale, size=(n, 1))
 
 
+class LogUniformLogPrior(pints.LogPrior):
+    r"""
+    Defines a log-uniform prior over a given range.
+
+    The range includes the lower and upper boundaries, so that any
+    point ``x`` with a non-zero prior must have ``0 < a <= x < b``.
+
+    In 1D this has pdf
+
+    .. math::
+        f(x|a,b)=\begin{cases}0,&\text{if }x\not\in
+        [a,b]\\\frac{1}{x \log(\frac{b}{a})}
+        ,&\text{if }x\in[a,b]\end{cases}.
+
+    A random variable :math:`X` distributed according to this pdf has
+    expectation
+
+    .. math::
+        \mathrm{E}(X)=\frac{b-a}{\log(b/a)}.
+
+    For example, to create a prior with :math:`x\in[1e-2,1e2]`, use::
+
+        p = pints.LogUniformLogPrior(1e-2, 1e2)
+
+    Extends :class:`LogPrior`.
+    """
+    def __init__(self, a, b):
+        if a <= 0:
+            raise ValueError("a must be > 0")
+        if b <= a:
+            raise ValueError("b must be > a > 0")
+
+        self._a = a
+        self._b = b
+        #constant for S1 evaluation
+        self._c = np.divide(1, np.log(np.divide(b, a)))
+
+    def __call__(self, x):
+        return scipy.stats.loguniform.logpdf(x, self._a, self._b)
+
+    def cdf(self, x):
+        """ See :meth:`LogPrior.cdf()`. """
+        return scipy.stats.loguniform.cdf(x, self._a, self._b)
+
+    def icdf(self, p):
+        """ See :meth:`LogPrior.icdf()`. """
+        return scipy.stats.loguniform.ppf(p, self._a, self._b)
+
+    def evaluateS1(self, x):
+        """ See :meth:`LogPrior.evaluateS1()`. """
+        dp = np.array(- 1 / x)
+        # Set values outside limits to nan
+        dp[(np.asarray(x) < self._a) | (np.asarray(x) > self._b)] = np.nan
+        return self(x), dp
+
+    def mean(self):
+        """ See :meth:`LogPrior.mean()`. """
+        return scipy.stats.loguniform.mean(self._a, self._b)
+
+    def n_parameters(self):
+        """ See :meth:`LogPrior.n_parameters()`. """
+        return 1
+
+    def sample(self, n=1):
+        """ See :meth:`LogPrior.sample()`. """
+        return scipy.stats.loguniform.rvs(self._a, self._b, size=(n, 1))
+
+
 class MultivariateGaussianLogPrior(pints.LogPrior):
     r"""
     Defines a multivariate Gaussian (log) prior with a given ``mean`` and


Test patch:
diff --git a/pints/tests/test_log_priors.py b/pints/tests/test_log_priors.py
index 499ac341..23949fd2 100755
--- a/pints/tests/test_log_priors.py
+++ b/pints/tests/test_log_priors.py
@@ -595,6 +595,34 @@ class TestPrior(unittest.TestCase):
         mean = np.mean(samples1).item()
         self.assertTrue(9. < mean < 11.)
 
+    def test_log_uniform_prior(self):
+
+        #Test input parameters
+        self.assertRaises(ValueError, pints.LogUniformLogPrior, 0, 1)
+        self.assertRaises(ValueError, pints.LogUniformLogPrior, 1, 1)
+
+        a = 1e-2
+        b = 1e2
+
+        p = pints.LogUniformLogPrior(a, b)
+
+        #all values below were calculated separately (not by scipy)
+        self.assertAlmostEqual(p.mean(), 10.856276311376536)
+
+        #test n_parameters
+        self.assertEqual(p.n_parameters(), 1)
+
+        points = [0.1, 63.0]
+        vals = [0.08225828662619909, -6.36346153275938]
+        dvals = [-10.0, -0.015873015873015872]
+
+        for point, val, dval in zip(points, vals, dvals):
+            test_val_1, test_dval = p.evaluateS1(point)
+            test_val_2 = p(point)
+            self.assertEqual(test_val_1, test_val_2)
+            self.assertAlmostEqual(test_val_1, val)
+            self.assertAlmostEqual(test_dval, dval)
+
     def test_log_normal_prior(self):
 
         # Test input parameters
@@ -657,6 +685,21 @@ class TestPrior(unittest.TestCase):
             self.assertAlmostEqual(pints_val, scipy_val)
             self.assertAlmostEqual(pints_deriv[0], hand_calc_deriv)
 
+    def test_log_uniform_prior_cdf_icdf(self):
+        p1 = pints.LogUniformLogPrior(1e-2, 1e2)
+        self.assertAlmostEqual(p1.cdf(0.1), 0.25)
+        self.assertAlmostEqual(p1.cdf(10), 0.75)
+        self.assertAlmostEqual(p1.icdf(0.25), 0.1)
+        self.assertAlmostEqual(p1.icdf(0.75), 10.0)
+
+    def test_log_uniform_prior_sampling(self):
+        p1 = pints.LogUniformLogPrior(1e-2, 1e2)
+        samples = p1.sample(1000000)
+        mean = p1.mean()
+        sample_mean = np.mean(samples)
+        self.assertEqual(len(samples), 1000000)
+        self.assertLessEqual(np.abs(sample_mean - mean), 0.1)
+
     def test_log_normal_prior_cdf_icdf(self):
         p1 = pints.LogNormalLogPrior(-3.5, 7.7)
         self.assertAlmostEqual(p1.cdf(1.1), 0.6797226585187124)