From 9cffcc76a39c36cb25aa376b2ddd33e54e95710e Mon Sep 17 00:00:00 2001 From: Michael Clerx Date: Thu, 5 Feb 2026 23:09:20 +0000 Subject: [PATCH 1/7] Adding TransformedLogLikelihood class --- docs/source/transformations.rst | 18 +++++++ pints/_transformation.py | 85 ++++++++++++++++++++++++++++-- pints/tests/test_transformation.py | 10 +++- 3 files changed, 108 insertions(+), 5 deletions(-) diff --git a/docs/source/transformations.rst b/docs/source/transformations.rst index 966851bef..bccd2d31f 100644 --- a/docs/source/transformations.rst +++ b/docs/source/transformations.rst @@ -31,6 +31,24 @@ Example:: transform = pints.LogTransformation(n_parameters) mcmc = pints.MCMCController(log_posterior, n_chains, x0, transform=transform) +Transformation types: + + - :class:`ComposedTransformation` + - :class:`IdentityTransformation` + - :class:`LogitTransformation` + - :class:`LogTransformation` + - :class:`RectangularBoundariesTransformation` + - :class:`ScalingTransformation` + - :class:`UnitCubeTransformation` + +Transformed classes: + + - :class:`Transformation` + - :class:`TransformedBoundaries` + - :class:`TransformedErrorMeasure` + - :class:`TransformedLogPDF` + - :class:`TransformedLogPrior` + Transformation types ******************** diff --git a/pints/_transformation.py b/pints/_transformation.py index bfcee66a6..ab016894b 100644 --- a/pints/_transformation.py +++ b/pints/_transformation.py @@ -32,25 +32,46 @@ class Transformation(): """ def convert_log_pdf(self, log_pdf): """ - Returns a transformed log-PDF class. + Returns a transformed :class:`pints.LogPDF`. + + If `log_pdf` is a :class:`LogPrior`, a :class:`TransformedLogPrior` + will be returned, which also transforms the output of the + :meth:`sample` method. + + If `log_pdf` is a :class:`LogLikelihood`, a + :class:`TransformedLogLikelihood` is returned, which is assumed to be + invariant with respect to the transform (because it is a probability of + the data, not the parameters). For all other types (including + ``LogPrior``) a non-invariant transform is used, see + :class:`TransformedLogPDF` for details. """ + if isinstance(log_pdf, pints.LogLikelihood): + return TransformedLogLikelihood(log_pdf, self) + if isinstance(log_pdf, pints.LogPrior): + return TransformedLogPrior(log_pdf, self) return TransformedLogPDF(log_pdf, self) def convert_log_prior(self, log_prior): """ - Returns a transformed log-prior class. + Deprecated function: Use :meth:`convert_log_pdf` instead. """ + # Deprecated on 2026-02-06 + import warnings + warnings.warn( + 'The method `convert_log_prior` is deprecated. Please use' + ' `convert_log_pdf` instead (which will automatically detect' + ' detect LogPDF subtypes).') return TransformedLogPrior(log_prior, self) def convert_error_measure(self, error_measure): """ - Returns a transformed error measure class. + Returns a transformed :class:`pints.ErrorMeasure`. """ return TransformedErrorMeasure(error_measure, self) def convert_boundaries(self, boundaries): """ - Returns a transformed boundaries class. + Returns a transformed :class:`pints.Boundaries` object. """ if isinstance(boundaries, pints.RectangularBoundaries): if self.elementwise(): @@ -1212,6 +1233,62 @@ def sample(self, n): return qs +class TransformedLogLikelihood(pints.LogLikelihood): + r""" + A :class:`pints.LogLikelihood` that accepts parameters in a transformed + search space. + + Unlike a :class:`TransformedLogPDF`, a likelihood (a probability of the + data, given fixed parameters) is invariant to a parameter transform (but + not to a data transform), and so no Jacobian term appears. Instead + + .. math:: + ??? + + + For the first order sensitivity, the transformation is done using + + .. math:: + ??? + + Extends :class:`pints.LogLikelihood`. + + Parameters + ---------- + log_likelihood + A :class:`pints.LogLikelihood`. + transformation + A :class:`pints.Transformation`. + """ + def __init__(self, log_likelihood, transformation): + self._log_likelihood = log_likelihood + self._transform = transformation + self._n_parameters = self._log_pdf.n_parameters() + if self._transform.n_parameters() != self._n_parameters: + raise ValueError('Number of parameters for log_likelihood and ' + 'transformation must match.') + + def __call__(self, q): + # Compute LogLikelihood in the model space + return self._log_likelihood(self._transform.to_model(q)) + + def evaluateS1(self, q): + """ See :meth:`LogPDF.evaluateS1()`. """ + + # Call evaluateS1 of LogLikelihood in the model space + logl, dlogl_nojac = self._error.evaluateS1(self._transform.to_model(q)) + + # Calculate the S1 using change of variable (see ErrorMeasure above) + jacobian = self._transform.jacobian(q) + dlogl = np.matmul(dlogl_nojac, jacobian) # Jacobian must be 2nd term + + return logl, dlogl + + def n_parameters(self): + """ See :meth:`LogPDF.n_parameters()`. """ + return self._n_parameters + + class UnitCubeTransformation(ScalingTransformation): """ Maps a parameter space onto the unit (hyper)cube. diff --git a/pints/tests/test_transformation.py b/pints/tests/test_transformation.py index 549098b9a..a3a90ffb6 100755 --- a/pints/tests/test_transformation.py +++ b/pints/tests/test_transformation.py @@ -954,7 +954,8 @@ def test_transformed_log_prior(self): d = 2 t = pints.LogTransformation(2) r = pints.UniformLogPrior([0.1, 0.1], [0.9, 0.9]) - tr = t.convert_log_prior(r) + tr = t.convert_log_pdf(r) + self.assertIsInstance(tr, pints.TransformedLogPrior) # Test sample n = 1 @@ -966,6 +967,13 @@ def test_transformed_log_prior(self): self.assertEqual(x.shape, (n, d)) self.assertTrue(np.all(x < 0.)) + # Test deprecated alias + with warnings.catch_warnings(record=True) as w: + tr = t.convert_log_prior(r) + self.assertEqual(len(w), 1) + self.assertIn('deprecated', str(w[0].message)) + self.assertIsInstance(tr, pints.TransformedLogPrior) + if __name__ == '__main__': unittest.main() From 22f1e8594649f4172a49214fb8e00340fab50449 Mon Sep 17 00:00:00 2001 From: Michael Clerx Date: Thu, 5 Feb 2026 23:24:25 +0000 Subject: [PATCH 2/7] Updated changelog --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aea4ad005..5c60cf640 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,8 @@ All notable changes to this project will be documented in this file. ## Unreleased - ### Added +- [#1730](https://github.com/pints-team/pints/pull/1730) Added a `TransformedLogLikelihood` class which, unlike generic `TransformedLogPDF` objects, is invariant with respect to the parameters. - [#1724](https://github.com/pints-team/pints/pull/1724) The `LogLikelihood` class has been reintroduced, to differentiate between probabilities of parameters and probabilities of data, given fixed parameters. - [#1724](https://github.com/pints-team/pints/pull/1724) Added `PooledLogLikelihood` and `SumOfIndependentLogLikelihoods`. - [#1716](https://github.com/pints-team/pints/pull/1716) PINTS is now tested on Python 3.14. @@ -15,10 +15,12 @@ All notable changes to this project will be documented in this file. - [#1724](https://github.com/pints-team/pints/pull/1724) Some methods that accepted `LogPDF`s now specifically require `LogLikelihood`s (e.g. `LogPosterior`, `NestedController`). - [#1713](https://github.com/pints-team/pints/pull/1713) PINTS now requires matplotlib 2.2 or newer. ### Deprecated +- [#1730](https://github.com/pints-team/pints/pull/1730) The method `Transformation.convert_log_prior` is deprecated, as `convert_log_pdf` now calls the appropriate method based on the type of `LogPDF` passed in. - [#1724](https://github.com/pints-team/pints/pull/1724) The classes `PooledLogPDF` and `SumOfIndependentLogPDFs` are deprecated, in favour of `PooledLogLikelihood` and `SumOfIndependentLogLikelihoods` respectively. - [#1508](https://github.com/pints-team/pints/pull/1508) The methods `OptimisationController.max_unchanged_iterations` and `set_max_unchanged_iterations` are deprecated, in favour of `function_tolerance` and `set_function_tolerance` respectively. ### Removed ### Fixed +- [#1730](https://github.com/pints-team/pints/pull/1730) Log-likelihoods are now transformed correctly. - [#1713](https://github.com/pints-team/pints/pull/1713) Fixed Numpy 2.4.1 compatibility issues. - [#1690](https://github.com/pints-team/pints/pull/1690) Fixed bug in optimisation controller if population size left at `None`. From 6551ac3c2bdc93ae984373c3ca7d8489813d8153 Mon Sep 17 00:00:00 2001 From: Michael Clerx Date: Fri, 6 Feb 2026 10:49:44 +0000 Subject: [PATCH 3/7] Tweaked wording in optimiser docstrings. --- pints/_optimisers/__init__.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pints/_optimisers/__init__.py b/pints/_optimisers/__init__.py index 31b7fb209..c0b49de7c 100644 --- a/pints/_optimisers/__init__.py +++ b/pints/_optimisers/__init__.py @@ -236,7 +236,8 @@ def xbest(self): def x_best(self): """ Returns the best position seen during an optimisation, i.e. the point - for which the minimal error or maximum LogPDF was observed. + for which the minimal error or maximum probability density was + observed. """ raise NotImplementedError @@ -245,7 +246,7 @@ def x_guessed(self): Returns the optimiser's current best estimate of where the optimum is. For many optimisers, this will simply be the point for which the - minimal error or maximum LogPDF was observed, so that + minimal error or maximum probability density was observed, so that ``x_guessed = x_best``. However, optimisers like :class:`pints.CMAES` and its derivatives, maintain a separate "best guess" value that does not necessarily correspond to any of the points evaluated during the From 30a1e63442d23bb5b8999852094b0064dc6011cc Mon Sep 17 00:00:00 2001 From: Michael Clerx Date: Fri, 6 Feb 2026 12:11:50 +0000 Subject: [PATCH 4/7] Added TransformedLogLikelihood to the docs --- docs/source/transformations.rst | 3 +++ pints/__init__.py | 1 + 2 files changed, 4 insertions(+) diff --git a/docs/source/transformations.rst b/docs/source/transformations.rst index bccd2d31f..8ae09eca4 100644 --- a/docs/source/transformations.rst +++ b/docs/source/transformations.rst @@ -46,6 +46,7 @@ Transformed classes: - :class:`Transformation` - :class:`TransformedBoundaries` - :class:`TransformedErrorMeasure` + - :class:`TransformedLogLikelihood` - :class:`TransformedLogPDF` - :class:`TransformedLogPrior` @@ -76,6 +77,8 @@ Transformed objects .. autoclass:: TransformedErrorMeasure +.. autoclass:: TransformedLogLikelihood + .. autoclass:: TransformedLogPDF .. autoclass:: TransformedLogPrior diff --git a/pints/__init__.py b/pints/__init__.py index 60ea52ac2..dded95107 100644 --- a/pints/__init__.py +++ b/pints/__init__.py @@ -275,6 +275,7 @@ def version(formatted=False): Transformation, TransformedBoundaries, TransformedErrorMeasure, + TransformedLogLikelihood, TransformedLogPDF, TransformedLogPrior, TransformedRectangularBoundaries, From 36bf64a63eaef43d27828af723662c7a5e382fbc Mon Sep 17 00:00:00 2001 From: Frankie Patten-Elliot Date: Sat, 7 Feb 2026 12:27:26 +0000 Subject: [PATCH 5/7] Update documentation and code for TransformedLogLikelihood class --- pints/_transformation.py | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/pints/_transformation.py b/pints/_transformation.py index ab016894b..c917c15a7 100644 --- a/pints/_transformation.py +++ b/pints/_transformation.py @@ -1238,18 +1238,23 @@ class TransformedLogLikelihood(pints.LogLikelihood): A :class:`pints.LogLikelihood` that accepts parameters in a transformed search space. - Unlike a :class:`TransformedLogPDF`, a likelihood (a probability of the - data, given fixed parameters) is invariant to a parameter transform (but - not to a data transform), and so no Jacobian term appears. Instead + Unlike a :class:`TransformedLogPDF`, a likelihood (a measure of how well + the data, $\boldsymbol{x}, is explained by a model, given fixed parameters) + is invariant to a parameter transform (but not to a data transform), + and so no Jacobian term appears. Instead for some :class:`Transformation` + $\boldsymbol{q}=\boldsymbol{f}(\boldsymbol{p})$ .. math:: - ??? - + $\underset{\boldsymbol{q}}{\text{max}}(\log L(\boldsymbol{q}|\boldsymbol{x})) = + \underset{\boldsymbol{q}}{\text{max}}(\log L(\boldsymbol{f}^{-1}(\boldsymbol{q}|\boldsymbol{x}))).$ For the first order sensitivity, the transformation is done using .. math:: - ??? + \frac{\partial \log L(\boldsymbol{q}|\boldsymbol{x})}{\partial q_i} &= + \frac{\partial \log L(\boldsymbol{f}^{-1}(\boldsymbol{q})|\boldsymbol{x})}{\partial q_i}\\ + &= \sum_l \frac{\partial \log L(\boldsymbol{p|\boldsymbol{x}})}{\partial p_l} + \frac{\partial p_l}{\partial q_i}. Extends :class:`pints.LogLikelihood`. @@ -1275,8 +1280,11 @@ def __call__(self, q): def evaluateS1(self, q): """ See :meth:`LogPDF.evaluateS1()`. """ + # Get parameters in the model space + p = self._transform.to_model(q) + # Call evaluateS1 of LogLikelihood in the model space - logl, dlogl_nojac = self._error.evaluateS1(self._transform.to_model(q)) + logl, dlogl_nojac = self._log_likelihood.evaluateS1(p) # Calculate the S1 using change of variable (see ErrorMeasure above) jacobian = self._transform.jacobian(q) From 225145ddce08be4371bdcd398499e9218a620720 Mon Sep 17 00:00:00 2001 From: Michael Clerx Date: Sat, 7 Feb 2026 16:54:54 +0000 Subject: [PATCH 6/7] Tiny fix to tex in docstring --- pints/_transformation.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/pints/_transformation.py b/pints/_transformation.py index c917c15a7..ead82d2ca 100644 --- a/pints/_transformation.py +++ b/pints/_transformation.py @@ -1239,19 +1239,19 @@ class TransformedLogLikelihood(pints.LogLikelihood): search space. Unlike a :class:`TransformedLogPDF`, a likelihood (a measure of how well - the data, $\boldsymbol{x}, is explained by a model, given fixed parameters) - is invariant to a parameter transform (but not to a data transform), - and so no Jacobian term appears. Instead for some :class:`Transformation` + the data, $\boldsymbol{x}, is explained by a model, given fixed parameters) + is invariant to a parameter transform (but not to a data transform), + and so no Jacobian term appears. Instead for some :class:`Transformation` $\boldsymbol{q}=\boldsymbol{f}(\boldsymbol{p})$ .. math:: - $\underset{\boldsymbol{q}}{\text{max}}(\log L(\boldsymbol{q}|\boldsymbol{x})) = - \underset{\boldsymbol{q}}{\text{max}}(\log L(\boldsymbol{f}^{-1}(\boldsymbol{q}|\boldsymbol{x}))).$ + \underset{\boldsymbol{q}}{\text{max}}(\log L(\boldsymbol{q}|\boldsymbol{x})) = + \underset{\boldsymbol{q}}{\text{max}}(\log L(\boldsymbol{f}^{-1}(\boldsymbol{q}|\boldsymbol{x}))). For the first order sensitivity, the transformation is done using .. math:: - \frac{\partial \log L(\boldsymbol{q}|\boldsymbol{x})}{\partial q_i} &= + \frac{\partial \log L(\boldsymbol{q}|\boldsymbol{x})}{\partial q_i} &= \frac{\partial \log L(\boldsymbol{f}^{-1}(\boldsymbol{q})|\boldsymbol{x})}{\partial q_i}\\ &= \sum_l \frac{\partial \log L(\boldsymbol{p|\boldsymbol{x}})}{\partial p_l} \frac{\partial p_l}{\partial q_i}. From 49d527b26bd107b466c286b9bc1c51f7918ff897 Mon Sep 17 00:00:00 2001 From: Michael Clerx Date: Sat, 7 Feb 2026 16:55:55 +0000 Subject: [PATCH 7/7] Tiny fix to tex in docstring --- pints/_transformation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pints/_transformation.py b/pints/_transformation.py index ead82d2ca..8e516c693 100644 --- a/pints/_transformation.py +++ b/pints/_transformation.py @@ -1242,7 +1242,7 @@ class TransformedLogLikelihood(pints.LogLikelihood): the data, $\boldsymbol{x}, is explained by a model, given fixed parameters) is invariant to a parameter transform (but not to a data transform), and so no Jacobian term appears. Instead for some :class:`Transformation` - $\boldsymbol{q}=\boldsymbol{f}(\boldsymbol{p})$ + :math:`\boldsymbol{q}=\boldsymbol{f}(\boldsymbol{p})` .. math:: \underset{\boldsymbol{q}}{\text{max}}(\log L(\boldsymbol{q}|\boldsymbol{x})) =