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`. diff --git a/docs/source/transformations.rst b/docs/source/transformations.rst index 966851bef..8ae09eca4 100644 --- a/docs/source/transformations.rst +++ b/docs/source/transformations.rst @@ -31,6 +31,25 @@ 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:`TransformedLogLikelihood` + - :class:`TransformedLogPDF` + - :class:`TransformedLogPrior` + Transformation types ******************** @@ -58,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, 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 diff --git a/pints/_transformation.py b/pints/_transformation.py index bfcee66a6..8e516c693 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,70 @@ 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 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` + :math:`\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`. + + 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()`. """ + + # Get parameters in the model space + p = self._transform.to_model(q) + + # Call evaluateS1 of LogLikelihood in the model space + logl, dlogl_nojac = self._log_likelihood.evaluateS1(p) + + # 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()