Repository: pints-team/pints

Issue:
UnitCubeTransformation or ScalingTranslationTransformation
I think some methods like having parameters that live on the unit cube, so we should maybe add a UnitCubeTransformation or a more general scaling & translation transformation?

We've already got a `ScalingTransformation`, which could maybe be adapted to also have an `offset` constructor argument?

There's also a `RectangularBoundariesTransformation` that maps from rectangular boundaries onto R^n. Perhaps this name should be changed as there's other transformations you might want to do based on rectangular boundaries?

Thoughts @chonlei ?

Comments:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9d172892..56fb6b94 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file.
 
 ### Added
 - [#1459](https://github.com/pints-team/pints/pull/1459) Added the `iRprop-` local optimiser.
+- [#1456](https://github.com/pints-team/pints/pull/1456) Added an optional `translation` to `ScalingTransform` and added a `UnitCubeTransformation` class.
 - [#1432](https://github.com/pints-team/pints/pull/1432) Added 2 new stochastic models: production and degradation model, Schlogl's system of chemical reactions. Moved the stochastic logistic model into `pints.stochastic` to take advantage of the `MarkovJumpModel`.
 - [#1420](https://github.com/pints-team/pints/pull/1420) The `Optimiser` class now distinguishes between a best-visited point (`x_best`, with score `f_best`) and a best-guessed point (`x_guessed`, with approximate score `f_guessed`). For most optimisers, the two values are equivalent. The `OptimisationController` still tracks `x_best` and `f_best` by default, but this can be modified using the methods `set_f_guessed_tracking` and `f_guessed_tracking`.
 - [#1417](https://github.com/pints-team/pints/pull/1417) Added a module `toy.stochastic` for stochastic models. In particular, `toy.stochastic.MarkovJumpModel` implements Gillespie's algorithm for easier future implementation of stochastic models.
diff --git a/docs/source/transformations.rst b/docs/source/transformations.rst
index 6c2ab145..38d9880e 100644
--- a/docs/source/transformations.rst
+++ b/docs/source/transformations.rst
@@ -44,6 +44,7 @@ Overview:
 - :class:`TransformedErrorMeasure`
 - :class:`TransformedLogPDF`
 - :class:`TransformedLogPrior`
+- :class:`UnitCubeTransformation`
 
 
 .. autoclass:: ComposedTransformation
@@ -67,3 +68,6 @@ Overview:
 .. autoclass:: TransformedLogPDF
 
 .. autoclass:: TransformedLogPrior
+
+.. autoclass:: UnitCubeTransformation
+
diff --git a/pints/__init__.py b/pints/__init__.py
index 6b1c0211..e315c237 100644
--- a/pints/__init__.py
+++ b/pints/__init__.py
@@ -269,6 +269,7 @@ from ._transformation import (
     TransformedErrorMeasure,
     TransformedLogPDF,
     TransformedLogPrior,
+    UnitCubeTransformation,
 )
 
 
diff --git a/pints/_transformation.py b/pints/_transformation.py
index cf0da5a4..0fe44538 100644
--- a/pints/_transformation.py
+++ b/pints/_transformation.py
@@ -834,23 +834,32 @@ class RectangularBoundariesTransformation(Transformation):
 
 class ScalingTransformation(Transformation):
     """
-    Scales the input parameters by multiplying with an array ``scalings``.
+    Scales the input parameters by multiplying with an array ``scalings`` and
+    adding an optional array ``translation``.
 
     The transformation from model parameters ``p`` to search parameters ``q``
     is performed as::
 
-        q = p * scalings
+        q = (p + translation) * scalings
 
     Its Jacobian matrix is a diagonal matrix with ``1 / scalings`` on the
     diagonal.
 
     Extends :class:`Transformation`.
     """
-    def __init__(self, scalings):
+    def __init__(self, scalings, translation=None):
         self._s = pints.vector(scalings)
         self._inv_s = 1. / self._s
         self._n_parameters = len(self._s)
 
+        self._translation = None
+        if translation is not None:
+            self._translation = pints.vector(translation)
+            if len(self._translation) != self._n_parameters:
+                raise ValueError(
+                    'Translation must be None or be a vector of the same'
+                    ' length as the scalings.')
+
     def elementwise(self):
         """ See :meth:`Transformation.elementwise()`. """
         return True
@@ -878,12 +887,16 @@ class ScalingTransformation(Transformation):
 
     def to_model(self, q):
         """ See :meth:`Transformation.to_model()`. """
-        q = pints.vector(q)
-        return self._inv_s * q
+        p = self._inv_s * pints.vector(q)
+        if self._translation is not None:
+            p -= self._translation
+        return p
 
     def to_search(self, p):
         """ See :meth:`Transformation.to_search()`. """
         p = pints.vector(p)
+        if self._translation is not None:
+            p = p + self._translation
         return self._s * p
 
 
@@ -1134,3 +1147,32 @@ class TransformedLogPrior(TransformedLogPDF, pints.LogPrior):
         for i, p in enumerate(ps):
             qs[i, :] = self._transform.to_search(p)
         return qs
+
+
+class UnitCubeTransformation(ScalingTransformation):
+    """
+    Maps a parameter space onto the unit (hyper)cube.
+
+    Transformations from model parameters ``p`` to search parameters ``q`` are
+    made as::
+
+        q = (p - lower) / (upper - lower)
+
+    Extends :class:`ScalingTransformation`.
+    """
+    def __init__(self, lower, upper):
+
+        # Check input
+        self._lower = pints.vector(lower)
+        self._upper = pints.vector(upper)
+        self._n_parameters = len(lower)
+        del lower, upper
+
+        if len(self._upper) != self._n_parameters:
+            raise ValueError(
+                'Lower and upper bounds must have the same length.')
+        if not np.all(self._upper > self._lower):
+            raise ValueError('Upper bounds must exceed lower bounds.')
+
+        super().__init__(1 / (self._upper - self._lower), -self._lower)
+


Test patch:
diff --git a/pints/tests/test_transformation.py b/pints/tests/test_transformation.py
index 2166c4b0..d04ff5cf 100755
--- a/pints/tests/test_transformation.py
+++ b/pints/tests/test_transformation.py
@@ -594,7 +594,7 @@ class TestRectangularBoundariesTransformation(unittest.TestCase):
 
 
 class TestScalingTransformation(unittest.TestCase):
-    # Test ScalingTransformation class
+    """ Tests the ScalingTransformation class, without a translation. """
 
     @classmethod
     def setUpClass(cls):
@@ -653,6 +653,127 @@ class TestScalingTransformation(unittest.TestCase):
         self.assertTrue(self.t.elementwise())
 
 
+class TestScalingTransformationWithTranslation(unittest.TestCase):
+    """ Tests the ScalingTransformation class, with a translation. """
+
+    @classmethod
+    def setUpClass(cls):
+        # Create Transformation class
+        cls.p = np.array([-77, 0.333, 5, 66.66])
+        cls.o = np.array([-100, 0, 5, 33.33])
+        cls.s = np.array([-177, 0.333, 10., 99.99])
+        cls.t = pints.ScalingTransformation(1 / cls.s, cls.o)
+        cls.x = [1., 1., 1., 1.]
+        cls.j = np.diag(cls.s)
+        cls.j_s1 = np.zeros((4, 4, 4))
+        cls.log_j_det = 10.9841922175539395
+        cls.log_j_det_s1 = np.zeros(4)
+
+    def test_creation(self):
+        # Tests creation options (at the moment just errors)
+        pints.ScalingTransformation(1 / self.s, None)
+        pints.ScalingTransformation(1 / self.s, list(self.o))
+        self.assertRaisesRegex(
+            ValueError, 'same length',
+            pints.ScalingTransformation, self.s, self.o[:-1])
+        self.assertRaisesRegex(
+            ValueError, 'same length',
+            pints.ScalingTransformation, self.s, [1, 2, 3, 4, 5])
+
+    def test_to_search(self):
+        # Test forward transform
+        self.assertTrue(np.allclose(self.t.to_search(self.p), self.x))
+
+    def test_to_model(self):
+        # Test inverse transform
+        self.assertTrue(np.allclose(self.t.to_model(self.x), self.p))
+
+    def test_n_parameters(self):
+        # Test n_parameters
+        self.assertEqual(self.t.n_parameters(), 4)
+
+    def test_jacobian(self):
+        # Test Jacobian
+        self.assertTrue(np.allclose(self.t.jacobian(self.x), self.j))
+
+    def test_jacobian_S1(self):
+        # Test Jacobian derivatives
+        calc_mat, calc_deriv = self.t.jacobian_S1(self.x)
+        self.assertTrue(np.allclose(calc_mat, self.j))
+        self.assertTrue(np.allclose(calc_deriv, self.j_s1))
+
+    def test_log_jacobian_det(self):
+        # Test log-Jacobian determinant
+        self.assertEqual(self.t.log_jacobian_det(self.x), self.log_j_det)
+
+    def test_log_jacobian_det_S1(self):
+        # Test log-Jacobian determinant derivatives
+        calc_val, calc_deriv = self.t.log_jacobian_det_S1(self.x)
+        self.assertEqual(calc_val, self.log_j_det)
+        self.assertTrue(np.all(np.equal(calc_deriv, self.log_j_det_s1)))
+
+    def test_retransform(self):
+        # Test forward transform the inverse transform
+        self.assertTrue(
+            np.allclose(self.p, self.t.to_model(self.t.to_search(self.p))))
+        self.assertTrue(
+            np.allclose(self.x, self.t.to_search(self.t.to_model(self.x))))
+
+    def test_elementwise(self):
+        # Test is elementwise
+        self.assertTrue(self.t.elementwise())
+
+
+class TestUnitCubeTransformation(unittest.TestCase):
+    """
+    Tests the UnitCubeTransformation class.
+
+    Most methods are tested in the ScalingTransformation tests
+    """
+
+    @classmethod
+    def setUpClass(cls):
+        # Create Transformation class
+        cls.lower = np.array([-1, 2, -3])
+        cls.upper = np.array([0, 4, -1])
+        cls.t = pints.UnitCubeTransformation(cls.lower, cls.upper)
+
+    def test_creation(self):
+        # Tests creation options (at the moment just errors)
+        pints.UnitCubeTransformation(self.lower, self.upper)
+        pints.UnitCubeTransformation(self.lower, [10, 10, 10])
+        pints.UnitCubeTransformation((-10, -20, -30), self.upper)
+
+        self.assertRaisesRegex(
+            ValueError, 'same length',
+            pints.UnitCubeTransformation, (1, 2), [3])
+        self.assertRaisesRegex(
+            ValueError, 'same length',
+            pints.UnitCubeTransformation, [3, 4, 5], (10, 10, 10, 10))
+        self.assertRaisesRegex(
+            ValueError, 'must exceed',
+            pints.UnitCubeTransformation, (1, 2), (0, 3))
+        self.assertRaisesRegex(
+            ValueError, 'must exceed',
+            pints.UnitCubeTransformation, (1, 2), (3, 1))
+        self.assertRaisesRegex(
+            ValueError, 'must exceed',
+            pints.UnitCubeTransformation, (1, 2), (1, 3))
+        self.assertRaisesRegex(
+            ValueError, 'must exceed',
+            pints.UnitCubeTransformation, (1, 2), (3, 2))
+
+    def test_to_search(self):
+        # Test forward transform
+        self.assertTrue(np.allclose(self.t.to_search(self.lower), [0, 0, 0]))
+        self.assertTrue(np.allclose(self.t.to_search(self.upper), [1, 1, 1]))
+
+    def test_to_model(self):
+        # Test inverse transform
+        self.assertTrue(np.allclose(self.t.to_model([0, 0, 0]), self.lower))
+        self.assertTrue(np.allclose(self.t.to_model([1, 1, 1]), self.upper))
+
+
 class TestTransformedWrappers(unittest.TestCase):
 
     def test_transformed_boundaries(self):