Repository: pints-team/pints

Issue:
`SingleChainMCMC` class errors with `set_log_pdf_storage(True)`
Hello,

I am using pints for MCMC, and I ran into an issue storing the log PDFs. This error seems to only occur with child classes of the `SingleChainMCMC` class, such as `NoUTurnMCMC`.

MWE:
```python
import pints
import pints.toy

# Create log pdf
log_pdf = pints.toy.GaussianLogPDF([5, 5], [[4, 1], [1, 3]])

# Create mcmc
x0 = [[2, 2]]
method = pints.NoUTurnMCMC

opt = pints.MCMCController(
    log_pdf,
    chains=1,
    x0=x0,
    method=method,
)

# opt.set_log_pdf_storage(False) # succeeds
opt.set_log_pdf_storage(True) # fails

opt.run()
```
which produces the stacktrace:
```python
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
TypeError: float() argument must be a string or a real number, not 'tuple'

The above exception was the direct cause of the following exception:

ValueError                                Traceback (most recent call last)
Cell In[14], [line 20](vscode-notebook-cell:?execution_count=14&line=20)
     [11](vscode-notebook-cell:?execution_count=14&line=11) opt = pints.MCMCController(
     [12](vscode-notebook-cell:?execution_count=14&line=12)     log_pdf,
     [13](vscode-notebook-cell:?execution_count=14&line=13)     chains=1,
     [14](vscode-notebook-cell:?execution_count=14&line=14)     x0=x0,
     [15](vscode-notebook-cell:?execution_count=14&line=15)     method=method,
     [16](vscode-notebook-cell:?execution_count=14&line=16) )
     [18](vscode-notebook-cell:?execution_count=14&line=18) opt.set_log_pdf_storage(True)
---> [20](vscode-notebook-cell:?execution_count=14&line=20) opt.run()

File ~/Documents/GitHub/pints/pints/_mcmc/__init__.py:757, in MCMCController.run(self)
    [754](https://file+.vscode-resource.vscode-cdn.net/Users/marcberliner/Documents/GitHub/pints/examples/sampling/~/Documents/GitHub/pints/pints/_mcmc/__init__.py:754) if store_evaluations:
    [755](https://file+.vscode-resource.vscode-cdn.net/Users/marcberliner/Documents/GitHub/pints/examples/sampling/~/Documents/GitHub/pints/pints/_mcmc/__init__.py:755)     # If accepted, update log_pdf and prior for logging
    [756](https://file+.vscode-resource.vscode-cdn.net/Users/marcberliner/Documents/GitHub/pints/examples/sampling/~/Documents/GitHub/pints/pints/_mcmc/__init__.py:756)     if accepted:
--> [757](https://file+.vscode-resource.vscode-cdn.net/Users/marcberliner/Documents/GitHub/pints/examples/sampling/~/Documents/GitHub/pints/pints/_mcmc/__init__.py:757)         current_logpdf[i] = fy
    [758](https://file+.vscode-resource.vscode-cdn.net/Users/marcberliner/Documents/GitHub/pints/examples/sampling/~/Documents/GitHub/pints/pints/_mcmc/__init__.py:758)         if prior is not None:
    [759](https://file+.vscode-resource.vscode-cdn.net/Users/marcberliner/Documents/GitHub/pints/examples/sampling/~/Documents/GitHub/pints/pints/_mcmc/__init__.py:759)             current_prior[i] = prior(y)

ValueError: setting an array element with a sequence.
```

Thanks for the great work!

Discussion:
@MichaelClerx: Thanks @MarcBerliner . Just ran the example and can reproduce the same error@MichaelClerx: OK, the `fy` variable will contain both fy and dfy/dp if a sensitivity-using method is used. Should also happen for multi-chain sensitivity-users, if we have those...

https://github.com/pints-team/pints/blob/9843b6402e4c2f0e8eb58f3f394b6d139212cbab/pints/_mcmc/__init__.py#L738-L757@MichaelClerx: @martinjrobins do you think we should store the sensitivities too, or update the code to ignore them?

At the moment the method to enable this is called `set_log_pdf_storage`, which strongly suggests we don't store them 🗡️ 

@MarcBerliner were you expecting the sensitivities (df/dp) to be returned ?@martinjrobins: I think updating the code to ignore them would fix the issue and agree that this fits the function name. I can't immediately think of a good reason to store them@MarcBerliner: > were you expecting the sensitivities (df/dp) to be returned ?

Thanks for looking into this, @MichaelClerx. I don’t need the sensitivities, only the `log_pdf` values. Calculating my objective function is expensive, so I’d like to store the `log_pdf` values alongside the samples to avoid recalculating them.

As a side note, I noticed that samplers that successfully returned a `log_pdf` vector returned values that differed from my objective function. I suspect the samplers are applying some internal rescaling, possibly related to the Jacobian or covariance matrix. It would be great if this rescaling could be removed from the `log_pdf` values.@MichaelClerx: Hi @MarcBerliner . Don't know if you saw, but the original issue has now been resolved in `main`.

W.r.t. the scaling: I couldn't recreate this! If you can, please open a new issue and include a short script to recreate

Solution patch:
diff --git a/pints/_mcmc/__init__.py b/pints/_mcmc/__init__.py
index ff566bf54..5c137d5bc 100644
--- a/pints/_mcmc/__init__.py
+++ b/pints/_mcmc/__init__.py
@@ -735,6 +735,8 @@ def run(self):
 
                     if reply is not None:
                         # Unpack reply into position, evaluation, and status
+                        # Note that evaluation is (fy, dfy) with sensitivities
+                        # enabled.
                         y, fy, accepted = reply
 
                         # Inverse transform to model space if transformation is
@@ -754,7 +756,10 @@ def run(self):
                         if store_evaluations:
                             # If accepted, update log_pdf and prior for logging
                             if accepted:
-                                current_logpdf[i] = fy
+                                if self._needs_sensitivities:
+                                    current_logpdf[i] = fy[0]
+                                else:
+                                    current_logpdf[i] = fy
                                 if prior is not None:
                                     current_prior[i] = prior(y)
 
@@ -818,7 +823,10 @@ def run(self):
                             # Check if accepted, if so, update log_pdf and
                             # prior to be logged
                             if accepted[i]:
-                                current_logpdf[i] = fys[i]
+                                if self._needs_sensitivities:
+                                    current_logpdf[i] = fys[i][0]
+                                else:
+                                    current_logpdf[i] = fys[i]
                                 if prior is not None:
                                     current_prior[i] = prior(ys[i])
 
@@ -1034,9 +1042,10 @@ def set_log_pdf_storage(self, store_in_memory=False):
         Store :class:`LogPDF` evaluations in memory as they are generated.
 
         By default, evaluations of the :class:`LogPDF` are not stored. This
-        method can be used to enable storage of the evaluations for the
-        accepted samples.
-        After running, evaluations can be obtained using :meth:`evaluations()`.
+        method can be used to enable (or disable) storage for the accepted
+        samples, in memory.
+
+        After running, evaluations can be obtained using :meth:`log_pdfs()`.
         """
         self._evaluations_in_memory = bool(store_in_memory)
 
diff --git a/pints/_mcmc/_adaptive_covariance.py b/pints/_mcmc/_adaptive_covariance.py
index 49d1c9545..c895daf06 100644
--- a/pints/_mcmc/_adaptive_covariance.py
+++ b/pints/_mcmc/_adaptive_covariance.py
@@ -23,7 +23,7 @@ class AdaptiveCovarianceMC(pints.SingleChainMCMC):
     A basic implementation is provided for each, which extending methods can
     choose to override.
 
-    Extends :class:`SingleChainMCMC`.
+    Extends :class:`SingleChainMCMC`, does not use sensitivities.
     """
 
     def __init__(self, x0, sigma0=None):
diff --git a/pints/_mcmc/_differential_evolution.py b/pints/_mcmc/_differential_evolution.py
index 8f8f4e9cb..feb87c673 100644
--- a/pints/_mcmc/_differential_evolution.py
+++ b/pints/_mcmc/_differential_evolution.py
@@ -29,7 +29,7 @@ class DifferentialEvolutionMCMC(pints.MultiChainMCMC):
     If ``x_proposed / x[i,r] > u ~ U(0,1)``, then
     ``x[i+1,r] = x_proposed``; otherwise, ``x[i+1,r] = x[i]``.
 
-    Extends :class:`MultiChainMCMC`.
+    Extends :class:`MultiChainMCMC`, does not use sensitivities.
 
     .. note::
         This sampler requires a number of chains :math:`n \ge 3`, and
diff --git a/pints/_mcmc/_dram_ac.py b/pints/_mcmc/_dram_ac.py
index 2d7d2b83e..e3d43144c 100644
--- a/pints/_mcmc/_dram_ac.py
+++ b/pints/_mcmc/_dram_ac.py
@@ -50,7 +50,7 @@ class DramACMC(pints.AdaptiveCovarianceMC):
     are then adapted as ``[scale_1, scale_2] * sigma``, where the
     scale factors are set using ``set_sigma_scale``.
 
-    *Extends:* :class:`GlobalAdaptiveCovarianceMC`
+    *Extends:* :class:`GlobalAdaptiveCovarianceMC`, does not use sensitivities.
 
     References
     ----------
diff --git a/pints/_mcmc/_dream.py b/pints/_mcmc/_dream.py
index 99e713c24..619476db0 100644
--- a/pints/_mcmc/_dream.py
+++ b/pints/_mcmc/_dream.py
@@ -53,7 +53,7 @@ class DreamMCMC(pints.MultiChainMCMC):
 
     Here b > 0, b* > 0,  1 >= p_g >= 0, 1 >= CR >= 0.
 
-    Extends :class:`MultiChainMCMC`.
+    Extends :class:`MultiChainMCMC`, does not use sensitivities.
 
     References
     ----------
diff --git a/pints/_mcmc/_emcee_hammer.py b/pints/_mcmc/_emcee_hammer.py
index 9cc7c0985..111ed6e30 100644
--- a/pints/_mcmc/_emcee_hammer.py
+++ b/pints/_mcmc/_emcee_hammer.py
@@ -34,6 +34,8 @@ class EmceeHammerMCMC(pints.MultiChainMCMC):
     ``1 / sqrt(z)`` if ``z`` is in  ``[1 / a, a]`` or to 0, otherwise (where
     ``a`` is a parameter with default value ``2``).
 
+    Extends :class:`MultiChainMCMC`, does not use sensitivities.
+
     References
     ----------
     .. [1] "emcee: The MCMC Hammer", Daniel Foreman-Mackey, David W. Hogg,
diff --git a/pints/_mcmc/_haario_ac.py b/pints/_mcmc/_haario_ac.py
index f2770f15a..33bafb84c 100644
--- a/pints/_mcmc/_haario_ac.py
+++ b/pints/_mcmc/_haario_ac.py
@@ -44,7 +44,7 @@ class HaarioACMC(pints.AdaptiveCovarianceMC):
         log lambda = log lambda + gamma (alpha - self._target_acceptance)
         gamma = adaptation_count^-eta
 
-    Extends :class:`AdaptiveCovarianceMC`.
+    Extends :class:`AdaptiveCovarianceMC`, does not use sensitivities.
 
     References
     ----------
diff --git a/pints/_mcmc/_haario_bardenet_ac.py b/pints/_mcmc/_haario_bardenet_ac.py
index 520bb74a7..07fde6622 100644
--- a/pints/_mcmc/_haario_bardenet_ac.py
+++ b/pints/_mcmc/_haario_bardenet_ac.py
@@ -42,7 +42,7 @@ class HaarioBardenetACMC(pints.AdaptiveCovarianceMC):
         log lambda = log lambda + gamma (alpha - self._target_acceptance)
         gamma = adaptation_count^-eta
 
-    Extends :class:`AdaptiveCovarianceMC`.
+    Extends :class:`AdaptiveCovarianceMC`, does not use sensitivities.
 
     References
     ----------
diff --git a/pints/_mcmc/_hamiltonian.py b/pints/_mcmc/_hamiltonian.py
index 9ed5dbb97..5840c3dca 100644
--- a/pints/_mcmc/_hamiltonian.py
+++ b/pints/_mcmc/_hamiltonian.py
@@ -46,7 +46,7 @@ class HamiltonianMCMC(pints.SingleChainMCMC):
     In particular, the algorithm we implement follows eqs. (4.14)-(4.16) in
     [1]_, since we allow different epsilon according to dimension.
 
-    Extends :class:`SingleChainMCMC`.
+    Extends :class:`SingleChainMCMC`, this method uses sensitivities.
 
     References
     ----------
@@ -190,7 +190,10 @@ def name(self):
         return 'Hamiltonian Monte Carlo'
 
     def needs_sensitivities(self):
-        """ See :meth:`pints.MCMCSampler.needs_sensitivities()`. """
+        """
+        This method requires sensitivities.
+
+        See :meth:`pints.MCMCSampler.needs_sensitivities()`. """
         return True
 
     def scaled_epsilon(self):
diff --git a/pints/_mcmc/_mala.py b/pints/_mcmc/_mala.py
index 0c05c3ada..f893ef16d 100644
--- a/pints/_mcmc/_mala.py
+++ b/pints/_mcmc/_mala.py
@@ -67,7 +67,7 @@ class MALAMCMC(pints.SingleChainMCMC):
     where :math:`\epsilon' = \epsilon \sqrt{M}` is given by the initial value
     of ``sigma0``.
 
-    Extends :class:`SingleChainMCMC`.
+    Extends :class:`SingleChainMCMC`, this method uses sensitivities.
 
     References
     ----------
@@ -181,7 +181,11 @@ def name(self):
         return 'Metropolis-Adjusted Langevin Algorithm (MALA)'
 
     def needs_sensitivities(self):
-        """ See :meth:`pints.MCMCSampler.needs_sensitivities()`. """
+        """
+        This method requires sensitivities.
+
+        See :meth:`pints.MCMCSampler.needs_sensitivities()`.
+        """
         return True
 
     def n_hyper_parameters(self):
diff --git a/pints/_mcmc/_metropolis.py b/pints/_mcmc/_metropolis.py
index 032e86d40..50871cc57 100644
--- a/pints/_mcmc/_metropolis.py
+++ b/pints/_mcmc/_metropolis.py
@@ -24,7 +24,7 @@ class MetropolisRandomWalkMCMC(pints.SingleChainMCMC):
 
     here Sigma is the covariance matrix of the proposal.
 
-    Extends :class:`SingleChainMCMC`.
+    Extends :class:`SingleChainMCMC`, does not use sensitivities.
 
     References
     ----------
diff --git a/pints/_mcmc/_monomial_gamma_hamiltonian.py b/pints/_mcmc/_monomial_gamma_hamiltonian.py
index d5e497481..cc0a6a429 100644
--- a/pints/_mcmc/_monomial_gamma_hamiltonian.py
+++ b/pints/_mcmc/_monomial_gamma_hamiltonian.py
@@ -63,7 +63,7 @@ class MonomialGammaHamiltonianMCMC(pints.SingleChainMCMC):
     In particular, the algorithm we implement follows eqs. (4.14)-(4.16) in
     [2]_, since we allow different epsilon according to dimension.
 
-    Extends :class:`SingleChainMCMC`.
+    Extends :class:`SingleChainMCMC`, this method uses sensitivities.
 
     References
     ----------
@@ -297,7 +297,11 @@ def name(self):
         return 'Monomial-Gamma Hamiltonian Monte Carlo'
 
     def needs_sensitivities(self):
-        """ See :meth:`pints.MCMCSampler.needs_sensitivities()`. """
+        """
+        This method requires sensitivities.
+
+        See also :meth:`pints.MCMCSampler.needs_sensitivities()`.
+        """
         return True
 
     def n_hyper_parameters(self):
diff --git a/pints/_mcmc/_nuts.py b/pints/_mcmc/_nuts.py
index b665b3469..9e5e71102 100644
--- a/pints/_mcmc/_nuts.py
+++ b/pints/_mcmc/_nuts.py
@@ -499,7 +499,7 @@ class NoUTurnMCMC(pints.SingleChainMCMC):
 
     Note: This sampler is only supported on Python versions 3.3 and newer.
 
-    Extends :class:`SingleChainMCMC`.
+    Extends :class:`SingleChainMCMC`, this method uses sensitivities.
 
     References
     ----------
@@ -664,7 +664,11 @@ def name(self):
         return 'No-U-Turn MCMC'
 
     def needs_sensitivities(self):
-        """ See :meth:`pints.MCMCSampler.needs_sensitivities()`. """
+        """
+        This method requires sensitivities.
+
+        See :meth:`pints.MCMCSampler.needs_sensitivities()`.
+        """
         return True
 
     def number_adaption_steps(self):
diff --git a/pints/_mcmc/_population.py b/pints/_mcmc/_population.py
index 53eafdc7b..be5a8d5d6 100644
--- a/pints/_mcmc/_population.py
+++ b/pints/_mcmc/_population.py
@@ -39,7 +39,7 @@ class PopulationMCMC(pints.SingleChainMCMC):
     ``delta_T = 1 / num_temperatures``, and the chain with ``T_i = 0`` is the
     one whose target distribution we want to sample.
 
-    Extends :class:`SingleChainMCMC`.
+    Extends :class:`SingleChainMCMC`, does not use sensitivities.
 
     References
     ----------
diff --git a/pints/_mcmc/_rao_blackwell_ac.py b/pints/_mcmc/_rao_blackwell_ac.py
index 4b6b90bec..69876c690 100644
--- a/pints/_mcmc/_rao_blackwell_ac.py
+++ b/pints/_mcmc/_rao_blackwell_ac.py
@@ -37,7 +37,7 @@ class RaoBlackwellACMC(pints.AdaptiveCovarianceMC):
         Y_t+1 ~ N(theta_t, lambda * sigma0) rather than
             Y_t+1 ~ N(theta_t, sigma0)
 
-    Extends :class:`AdaptiveCovarianceMC`.
+    Extends :class:`AdaptiveCovarianceMC`, does not use sensitivities.
 
     References
     ----------
diff --git a/pints/_mcmc/_relativistic.py b/pints/_mcmc/_relativistic.py
index adff93555..2c4b73441 100644
--- a/pints/_mcmc/_relativistic.py
+++ b/pints/_mcmc/_relativistic.py
@@ -57,7 +57,7 @@ class RelativisticMCMC(pints.SingleChainMCMC):
     In particular, the algorithm we implement follows eqs. in section 2.1 of
     [1]_.
 
-    Extends :class:`SingleChainMCMC`.
+    Extends :class:`SingleChainMCMC`, this method uses sensitivities.
 
     References
     ----------
@@ -325,7 +325,11 @@ def name(self):
         return 'Relativistic MCMC'
 
     def needs_sensitivities(self):
-        """ See :meth:`pints.MCMCSampler.needs_sensitivities()`. """
+        """
+        This method requires sensitivities.
+
+        See :meth:`pints.MCMCSampler.needs_sensitivities()`.
+        """
         return True
 
     def _sample_momentum(self):
diff --git a/pints/_mcmc/_slice_doubling.py b/pints/_mcmc/_slice_doubling.py
index c55bb0bc8..62565ede6 100644
--- a/pints/_mcmc/_slice_doubling.py
+++ b/pints/_mcmc/_slice_doubling.py
@@ -109,7 +109,7 @@ class SliceDoublingMCMC(pints.SingleChainMCMC):
     :math:`\epsilon \sim \text{exp}(1)` and define the slice as
     :math:`S = {x : z < g(x)}`.
 
-    Extends :class:`SingleChainMCMC`.
+    Extends :class:`SingleChainMCMC`, does not use sensitivities.
 
     References
     ----------
diff --git a/pints/_mcmc/_slice_rank_shrinking.py b/pints/_mcmc/_slice_rank_shrinking.py
index 1716251f9..dfd52c2b8 100644
--- a/pints/_mcmc/_slice_rank_shrinking.py
+++ b/pints/_mcmc/_slice_rank_shrinking.py
@@ -63,7 +63,7 @@ class SliceRankShrinkingMCMC(pints.SingleChainMCMC):
     :math:`\epsilon \sim \text{exp}(1)` and define the slice as
     :math:`S = {x : z < log f(x)}`.
 
-    Extends :class:`SingleChainMCMC`.
+    Extends :class:`SingleChainMCMC`, this method uses sensitivities.
 
     References
     ----------
@@ -156,7 +156,11 @@ def name(self):
         return 'Slice Sampling - Covariance-Adaptive: Rank Shrinking.'
 
     def needs_sensitivities(self):
-        """ See :meth:`pints.MCMCSampler.needs_sensitivities()`. """
+        """
+        This method requires sensitivities.
+
+        See :meth:`pints.MCMCSampler.needs_sensitivities()`.
+        """
         return True
 
     def n_hyper_parameters(self):
diff --git a/pints/_mcmc/_slice_stepout.py b/pints/_mcmc/_slice_stepout.py
index ed6e8fa63..e675910b9 100644
--- a/pints/_mcmc/_slice_stepout.py
+++ b/pints/_mcmc/_slice_stepout.py
@@ -120,7 +120,7 @@ class SliceStepoutMCMC(pints.SingleChainMCMC):
     :math:`\epsilon \sim \text{exp}(1)` and define the slice as
     :math:`S = {x : z < g(x)}`.
 
-    Extends :class:`SingleChainMCMC`.
+    Extends :class:`SingleChainMCMC`, does not use sensitivities.
 
     References
     ----------


Test patch:
diff --git a/pints/tests/test_mcmc_controller.py b/pints/tests/test_mcmc_controller.py
index 98220805b..5c213a9e8 100755
--- a/pints/tests/test_mcmc_controller.py
+++ b/pints/tests/test_mcmc_controller.py
@@ -690,6 +690,26 @@ def test_log_pdf_storage_in_memory_single(self):
             likelihoods = [self.log_likelihood(x) for x in chain]
             self.assertTrue(np.all(evals[i] == likelihoods))
 
+        # Test with a sensitivity-using method
+        mcmc = pints.MCMCController(
+            self.log_posterior, n_chains, xs, method=pints.MALAMCMC)
+        mcmc.set_max_iterations(n_iterations)
+        mcmc.set_log_to_screen(False)
+        mcmc.set_log_pdf_storage(True)
+        chains = mcmc.run()
+        evals = mcmc.log_pdfs()
+        self.assertEqual(len(evals.shape), 3)
+        self.assertEqual(evals.shape[0], n_chains)
+        self.assertEqual(evals.shape[1], n_iterations)
+        self.assertEqual(evals.shape[2], 3)
+        for i, chain in enumerate(chains):
+            posteriors = [self.log_posterior(x) for x in chain]
+            self.assertTrue(np.all(evals[i, :, 0] == posteriors))
+            likelihoods = [self.log_likelihood(x) for x in chain]
+            self.assertTrue(np.all(evals[i, :, 1] == likelihoods))
+            priors = [self.log_prior(x) for x in chain]
+            self.assertTrue(np.all(evals[i, :, 2] == priors))
+
         # Test disabling again
         mcmc = pints.MCMCController(self.log_posterior, n_chains, xs)
         mcmc.set_max_iterations(n_iterations)
@@ -706,7 +726,9 @@ def test_log_pdf_storage_in_memory_multi(self):
         x0 = np.array(self.real_parameters) * 1.05
         x1 = np.array(self.real_parameters) * 1.15
         x2 = np.array(self.real_parameters) * 0.95
-        xs = [x0, x1, x2]
+        x3 = np.array(self.real_parameters) * 0.95
+        x4 = np.array(self.real_parameters) * 0.95
+        xs = [x0, x1, x2, x3, x4]
         n_chains = len(xs)
         n_iterations = 100
         meth = pints.DifferentialEvolutionMCMC
@@ -737,6 +759,28 @@ def test_log_pdf_storage_in_memory_multi(self):
             priors = [self.log_prior(x) for x in chain]
             self.assertTrue(np.all(evals[i, :, 2] == priors))
 
+        # Test with a sensitivity-using multi-chain method
+        # We don't have any of these!
+        mcmc = pints.MCMCController(
+            self.log_posterior, n_chains, xs,
+            method=FakeMultiChainSamplerWithSensitivities)
+        mcmc.set_max_iterations(n_iterations)
+        mcmc.set_log_to_screen(False)
+        mcmc.set_log_pdf_storage(True)
+        chains = mcmc.run()
+        evals = mcmc.log_pdfs()
+        self.assertEqual(len(evals.shape), 3)
+        self.assertEqual(evals.shape[0], n_chains)
+        self.assertEqual(evals.shape[1], n_iterations)
+        self.assertEqual(evals.shape[2], 3)
+        for i, chain in enumerate(chains):
+            posteriors = [self.log_posterior(x) for x in chain]
+            self.assertTrue(np.all(evals[i, :, 0] == posteriors))
+            likelihoods = [self.log_likelihood(x) for x in chain]
+            self.assertTrue(np.all(evals[i, :, 1] == likelihoods))
+            priors = [self.log_prior(x) for x in chain]
+            self.assertTrue(np.all(evals[i, :, 2] == priors))
+
         # Test with a loglikelihood
         mcmc = pints.MCMCController(
             self.log_likelihood, n_chains, xs, method=meth)
@@ -1651,6 +1695,29 @@ def tell(self, fx):
         return None if x is None else (x, fx, np.array([True] * self._n))
 
 
+class FakeMultiChainSamplerWithSensitivities(pints.MultiChainMCMC):
+    """
+    Fake sampler that pretends to be a multi-chain method that uses
+    sensitivities. At the moment (2024-12-18) we don't have these in PINTS, but
+    we need to check that code potentially handling this type of sampler exists
+    or raises not-implemented errors.
+    """
+    def ask(self):
+        self._xs = self._x0 + 1e-3 * np.random.uniform(
+            size=(self._n_chains, self._n_parameters))
+        return self._xs
+
+    def current_log_pdfs(self):
+        return self._fxs
+
+    def tell(self, fxs):
+        self._fxs = fxs
+        return self._xs, self._fxs, [True] * self._n_chains
+
+    def needs_sensitivities(self):
+        return True
+
+
 class TestMCMCControllerSingleChainStorage(unittest.TestCase):
     """
     Tests storage of samples and evaluations to disk, running with a