From 099c44b954484110de992f7ee24e636d1ee2925c Mon Sep 17 00:00:00 2001 From: lorcandelaney Date: Wed, 14 Aug 2019 21:35:04 +0100 Subject: [PATCH] Add Generalised Elliptical method and tests. --- docs/source/mcmc_samplers/index.rst | 3 +- .../slice_generalised_elliptical_mcmc.rst | 7 + pints/__init__.py | 1 + pints/_mcmc/_slice_generalised_elliptical.py | 361 ++++++++++++++++++ .../test_mcmc_slice_generalised_elliptical.py | 210 ++++++++++ 5 files changed, 581 insertions(+), 1 deletion(-) create mode 100644 docs/source/mcmc_samplers/slice_generalised_elliptical_mcmc.rst create mode 100644 pints/_mcmc/_slice_generalised_elliptical.py create mode 100644 pints/tests/test_mcmc_slice_generalised_elliptical.py diff --git a/docs/source/mcmc_samplers/index.rst b/docs/source/mcmc_samplers/index.rst index 871b086a6..f474b4b9d 100644 --- a/docs/source/mcmc_samplers/index.rst +++ b/docs/source/mcmc_samplers/index.rst @@ -22,4 +22,5 @@ interface, that can be used to sample from an unknown metropolis_mcmc population_mcmc slice_doubling_mcmc - slice_stepout_mcmc \ No newline at end of file + slice_stepout_mcmc + slice_generalised_elliptical_mcmc diff --git a/docs/source/mcmc_samplers/slice_generalised_elliptical_mcmc.rst b/docs/source/mcmc_samplers/slice_generalised_elliptical_mcmc.rst new file mode 100644 index 000000000..d9f564519 --- /dev/null +++ b/docs/source/mcmc_samplers/slice_generalised_elliptical_mcmc.rst @@ -0,0 +1,7 @@ +************************************************** +Slice Sampling - Generalised Elliptical MCMC +************************************************** + +.. currentmodule:: pints + +.. autoclass:: SliceGeneralisedEllipticalMCMC diff --git a/pints/__init__.py b/pints/__init__.py index c7e085eb5..436dac675 100644 --- a/pints/__init__.py +++ b/pints/__init__.py @@ -205,6 +205,7 @@ def version(formatted=False): from ._mcmc._metropolis import MetropolisRandomWalkMCMC from ._mcmc._slice_stepout import SliceStepoutMCMC from ._mcmc._slice_doubling import SliceDoublingMCMC +from ._mcmc._slice_generalised_elliptical import SliceGeneralisedEllipticalMCMC # diff --git a/pints/_mcmc/_slice_generalised_elliptical.py b/pints/_mcmc/_slice_generalised_elliptical.py new file mode 100644 index 000000000..d8e42e241 --- /dev/null +++ b/pints/_mcmc/_slice_generalised_elliptical.py @@ -0,0 +1,361 @@ +# -*- coding: utf-8 -*- +# +# Generalised Elliptical Slice Sampling +# +# This file is part of PINTS. +# Copyright (c) 2017-2019, University of Oxford. +# For licensing information, see the LICENSE file distributed with the PINTS +# software package. +# +from __future__ import absolute_import, division +from __future__ import print_function, unicode_literals +import pints +import numpy as np +from scipy import optimize +from scipy import special + + +class SliceGeneralisedEllipticalMCMC(pints.SingleChainMCMC): + """ + *Extends:* :class:`SingleChainMCMC` + """ + + def __init__(self, x0, sigma0=None): + super(SliceGeneralisedEllipticalMCMC, self).__init__(x0, sigma0) + + # Set initial state + self._x0 = np.asarray(x0, dtype=float) + self._running = False + self._ready_for_tell = False + self._active_sample = None + self._active_sample_pi_log_pdf = None + self._proposed_sample = None + self._proposed_sample_pi_log_pdf = None + self._l_log_y = None + self._prepare = True + self._given_starting_points = None + + # Groups used for maximum-likelihood ``t`` parameters + self._groups = None + self._starts_mean = np.ones(self._n_parameters) + self._starts_std = 2 + self._group_size = 10 + + # Arrays of ``t`` distribution parameters for both groups + self._t_mu = [] + self._t_Sigma = [] + self._t_nu = [] + + # Group index: False for group 1, True for group 2 + self._index_active_group = False + + # Sample index + self._index_active_sample = 0 + + # Variable used to define new ellipse for ESS + self._ess_nu = None + + # Initial proposal and angles bracked + self._phi = None + self._phi_min = None + self._phi_max = None + + def ask(self): + """ See :meth:`SingleChainMCMC.ask()`. """ + + # Check ask/tell pattern + if self._ready_for_tell: + raise RuntimeError('Ask() called when expecting call to tell().') + + # Initialise on first call + if not self._running: + self._running = True + + # Very first iteration + if self._active_sample is None: + + # Ask for the log pdf of x0 + self._ready_for_tell = True + return np.array(self._x0, copy=True) + + # Prepare for ESS update + if self._prepare: + self._ready_for_tell = True + return np.array(self._active_sample, copy=True) + + # Draw proposal + self._proposed_sample = ( + (self._active_sample - self._t_mu[ + not self._index_active_group]) * np.cos(self._phi) + + (self._ess_nu - self._t_mu[ + not self._index_active_group]) * np.sin(self._phi) + + self._t_mu[not self._index_active_group]) + + # Send new point for to check + self._ready_for_tell = True + return np.array(self._proposed_sample, copy=True) + + def tell(self, reply): + """ See :meth:`pints.SingleChainMCMC.tell()`. """ + + # Check ask/tell pattern + if not self._ready_for_tell: + raise RuntimeError('Tell called before proposal was set.') + self._ready_for_tell = False + + # Unpack reply + fx = np.asarray(reply, dtype=float) + + # Very first call + if self._active_sample is None: + + # Check first point is somewhere sensible + if not np.isfinite(fx): + raise ValueError( + 'Initial point for MCMC must have finite logpdf.') + + # Update current sample, and initialise proposed sample for next + # iteration + self._active_sample = np.array(self._x0, copy=True) + + # Initialise array of groups + if self._given_starting_points is None: + starts = np.random.normal( + loc=self._starts_mean, scale=self._starts_std, size=( + 2 * self._group_size - 1, self._n_parameters)) + else: + starts = self._given_starting_points + + starts = np.concatenate(([self._x0], starts)) + self._groups = [starts[:self._group_size, :], + starts[self._group_size:, :]] + + # Parameters for t distributions + for group in self._groups: + mu, Sigma, nu = self._fit_mvstud(group) + self._t_mu.append(np.array(mu, copy=True)) + self._t_Sigma.append(np.array(Sigma, copy=True)) + self._t_nu.append(nu) + + self._prepare = True + + # Return first point in chain, which is x0 + return np.array(self._active_sample, copy=True) + + # Index of non-active group + index = not self._index_active_group + + # t parameters used for the GESS update + t_nu = self._t_nu[index] + t_Sigma = np.array(self._t_Sigma[index], copy=True) + t_invSigma = np.linalg.inv(t_Sigma) + t_mu = np.array(self._t_mu[index], copy=True) + + # Prepare for ESS update + if self._prepare: + # Store pi_log_pdf of active sample + self._active_sample_pi_log_pdf = fx + + # Obtain parameters for inverse gamma distribution + ig_alpha = (self._n_parameters + t_nu) / 2 + ig_beta = 0.5 * ( + t_nu + np.dot((self._active_sample - t_mu), np.dot( + t_invSigma, (self._active_sample - t_mu)))) + ig_s = 1. / np.random.gamma(ig_alpha, 1. / ig_beta) + + # Covariance matrix for Elliptical Slice Sampling update + ess_Sigma = ig_s * t_Sigma + + # Draw ``nu`` from Gaussian prior + self._ess_nu = np.random.multivariate_normal(t_mu, ess_Sigma) + + # Set log-likelihood treshold for ESS update + u = np.random.uniform() + self._l_log_y = ( + self._active_sample_pi_log_pdf - self._logt( + self._active_sample, t_mu, t_invSigma, t_nu) + np.log(u)) + + # Draw an initial proposal and define bracket + self._phi = np.random.uniform(0, 2 * np.pi) + self._phi_min = self._phi - 2 * np.pi + self._phi_max = self._phi + + self._prepare = False + return None + + # Log likelihood of proposal + log_pi_proposed = fx + log_t_proposed = self._logt( + self._proposed_sample, t_mu, t_invSigma, t_nu) + log_l_proposed = log_pi_proposed - log_t_proposed + + # Acceptance Check + if log_l_proposed > self._l_log_y: + + # Replace active sample with new accepted proposal + self._groups[self._index_active_group][ + self._index_active_sample] = np.array( + self._proposed_sample, copy=True) + + # Manage indices + if self._index_active_sample == self._group_size - 1: + self._index_active_sample = 0 + self._index_active_group = not self._index_active_group + + # Update MLE parameters for non-active group + mu, Sigma, nu = self._fit_mvstud( + self._groups[not self._index_active_group]) + self._t_mu[ + not self._index_active_group] = np.array(mu, copy=True) + self._t_Sigma[ + not self._index_active_group] = np.array(Sigma, copy=True) + self._t_nu[not self._index_active_group] = nu + + else: + self._index_active_sample += 1 + + # Update active sample + self._active_sample = np.array( + self._groups[self._index_active_group] + [self._index_active_sample], copy=True) + + self._prepare = True + return np.array(self._proposed_sample, copy=True) + + else: + # Shrink bracket + if self._phi < 0: + self._phi_min = self._phi + else: + self._phi_max = self._phi + + # Draw new sample + self._phi = np.random.uniform(self._phi_min, self._phi_max) + + return None + + # Function for computing the maximum likelihood for multivariate t + # distribution parameters + def _fit_mvstud(self, data, tolerance=1e-6): + def opt_nu(delta_iobs, nu): + def func0(nu): + w_iobs = (nu + dim) / (nu + delta_iobs) + f = -special.psi(nu / 2) + np.log(nu / 2) + np.sum( + np.log(w_iobs)) / n - np.sum( + w_iobs) / n + 1 + special.psi(( + nu + dim) / 2) - np.log((nu + dim) / 2) + return f + + if func0(1e6) >= 0: + nu = np.inf + else: + nu = optimize.brentq(func0, 1e-6, 1e6) + return nu + + # Extrapolate information about data: obtain dimention and number of + # chains in the group + data = data.T + (dim, n) = data.shape + + # Initialize mu_0, Sigma_0, nu_0 + mu = np.array([np.median(data, 1)]).T + Sigma = np.cov(data) * (n - 1) / n + 1e-1 * np.eye(dim) + nu = 20 + last_nu = 0 + + # Loop + while np.abs(last_nu - nu) > tolerance: + + # Sum the distances of each point from the mean + diffs = data - mu + delta_iobs = np.sum(diffs * np.linalg.solve(Sigma, diffs), 0) + + # update nu + last_nu = nu + nu = opt_nu(delta_iobs, nu) + if nu == np.inf: + nu = 1e6 + return mu.T[0], Sigma, nu + + w_iobs = (nu + dim) / (nu + delta_iobs) + + # update Sigma + Sigma = np.dot(w_iobs * diffs, diffs.T) / n + + # update mu + mu = np.sum(w_iobs * data, 1) / sum(w_iobs) + mu = np.array([mu]).T + + return mu.T[0], Sigma, nu + + # Log density of multivariate ``t`` distribution + def _logt(self, x, mu, invSigma, nu): + return - (self._n_parameters + nu) / 2 * np.log( + 1 + np.dot(x - mu, np.dot(invSigma, x - mu)) / nu) + + def name(self): + """ See :meth:`pints.MCMCSampler.name()`. """ + return 'Generalised Elliptical Slice Sampling' + + def set_starts_mean(self, mean): + """ + Sets mean of the Gaussian distribution from which we + draw the starting samples. + """ + if type(mean) == int or float: + mean = np.full((len(self._x0)), mean) + else: + mean = np.asarray(mean) + self._starts_mean = mean + + def set_starts_std(self, std): + """ + Sets standard deviation of the Gaussian distribution from which we + draw the starting samples. + """ + if std <= 0: + raise ValueError("""Standard deviation of the Gaussian distribution + from which we draw the starting samples should be positive""") + self._starts_std = std + + def set_group_size(self, group_size): + """ + Sets size of group of starting points. + """ + if group_size <= 0: + raise ValueError("""Each group of starting points should have at least + one value.""") + self._group_size = group_size + + def get_starts_mean(self): + """ + Returns mean of the Gaussian distribution from which we + draw the starting samples. + """ + return self._starts_mean + + def get_starts_std(self): + """ + Returns standard deviation of the Gaussian distribution from which we + draw the starting samples. + """ + return self._starts_std + + def get_group_size(self): + """ + Returns size of the groups of starting points. + """ + return self._group_size + + def give_initial_points(self, points): + """ + Sets starting points. + """ + points = np.asarray(points) + if points.shape[0] != 2 * self._group_size - 1: + raise ValueError("""The array of starting points should include ``2 * + group_size - 1`` values.""") + if points.shape[1] != self._n_parameters: + raise ValueError("""The dimensions of each starting point should be equal + to the number of parameters.""") + self._given_starting_points = points diff --git a/pints/tests/test_mcmc_slice_generalised_elliptical.py b/pints/tests/test_mcmc_slice_generalised_elliptical.py new file mode 100644 index 000000000..4d1f72b1e --- /dev/null +++ b/pints/tests/test_mcmc_slice_generalised_elliptical.py @@ -0,0 +1,210 @@ +# -*- coding: utf-8 -*- +# +# Tests the basic methods of the Generalised Elliptical Slice Sampling Routine. +# +# This file is part of PINTS. +# Copyright (c) 2017-2019, University of Oxford. +# For licensing information, see the LICENSE file distributed with the PINTS +# software package. +# + +import unittest +import numpy as np + +import pints +debug = False + + +class TestSliceGeneralisedElliptical(unittest.TestCase): + """ + Tests the basic methods of the Generalised Elliptical Slice Sampling + routine. + + Please refer to the _slice_generalised_elliptical.py script in ..\_mcmc + """ + def test_initialisation(self): + """ + Tests whether all instance attributes are initialised correctly. + """ + # Create mcmc + x0 = np.array([2, 4]) + mcmc = pints.SliceGeneralisedEllipticalMCMC(x0) + + # Test attributes initialisation + self.assertFalse(mcmc._running) + self.assertFalse(mcmc._ready_for_tell) + self.assertEqual(mcmc._active_sample, None) + self.assertEqual(mcmc._proposed_sample, None) + + def test_first_run(self): + """ + Tests the very first run of the sampler. + """ + + # Set seed for testing + np.random.seed(2) + + # Create log pdf + log_pdf = pints.toy.GaussianLogPDF([2, 4], [[1, 0], [0, 3]]) + + # Create mcmc + x0 = np.array([2., 4.]) + mcmc = pints.SliceGeneralisedEllipticalMCMC(x0) + + # Ask should fail if _ready_for_tell flag is True + with self.assertRaises(RuntimeError): + mcmc._ready_for_tell = True + mcmc.ask() + + # Undo changes + mcmc._ready_for_tell = False + + # Check whether _running flag becomes True when ask() is called + # Check whether first iteration of ask() returns x0 + self.assertFalse(mcmc._running) + self.assertTrue(np.all(mcmc.ask() == x0)) + self.assertTrue(mcmc._running) + self.assertTrue(mcmc._ready_for_tell) + + # Tell should fail when log pdf of x0 is infinite + with self.assertRaises(ValueError): + fx = np.inf + mcmc.tell(fx) + + # Calculate log pdf for x0 + fx = log_pdf.evaluateS1(x0)[0] + + # Tell should fail when _ready_for_tell is False + with self.assertRaises(RuntimeError): + mcmc._ready_for_tell = False + mcmc.tell(fx) + + # Undo changes + mcmc._ready_for_tell = True + + # Test first iteration of tell(). The first point in the chain + # should be x0 + self.assertTrue(np.all(mcmc.tell(fx) == x0)) + + # We update the current sample + self.assertTrue(np.all(mcmc._active_sample == x0)) + + def test_full_run(self): + """ + Tests a full run. + """ + + # Set seed for testing + np.random.seed(2) + + # Create log pdf + log_pdf = pints.toy.GaussianLogPDF([2, 4], [[1, 0], [0, 3]]) + + # Create mcmc + x0 = np.array([0, 0]) + mcmc = pints.SliceGeneralisedEllipticalMCMC(x0) + + # First run + x = mcmc.ask() + fx = log_pdf.evaluateS1(x)[0] + sample = mcmc.tell(fx) + self.assertTrue(mcmc._prepare) + self.assertTrue(np.all(sample == x0)) + self.assertEqual(len(mcmc._groups), 2) + self.assertEqual(len(mcmc._groups[0]), 10) + self.assertEqual(len(mcmc._groups[1]), 10) + self.assertTrue(np.all(x0 == mcmc._active_sample)) + self.assertTrue(np.all(x0 == mcmc._groups[0][0])) + self.assertEqual(len(mcmc._t_mu), 2) + self.assertEqual(len(mcmc._t_Sigma), 2) + self.assertEqual(len(mcmc._t_nu), 2) + + # Second run + x = mcmc.ask() + self.assertTrue(np.all(x == x0)) + fx = log_pdf.evaluateS1(x)[0] + sample = mcmc.tell(fx) + self.assertEqual(mcmc._active_sample_pi_log_pdf, fx) + self.assertFalse(mcmc._prepare) + + # Third run + x = mcmc.ask() + self.assertFalse(mcmc._prepare) + fx = log_pdf.evaluateS1(x)[0] + sample = mcmc.tell(fx) + self.assertTrue(sample is not None) + self.assertTrue(np.all(mcmc._groups[0][0] == x)) + self.assertEqual(mcmc._index_active_sample, 1) + self.assertTrue(np.all(mcmc._groups[0][1] == mcmc._active_sample)) + self.assertTrue(mcmc._prepare) + + # Fourth run + x = mcmc.ask() + self.assertTrue(np.all(mcmc._groups[0][1] == x)) + fx = log_pdf.evaluateS1(x)[0] + sample = mcmc.tell(fx) + self.assertEqual(fx, mcmc._active_sample_pi_log_pdf) + self.assertTrue(np.all(mcmc._groups[0][1] == mcmc._active_sample)) + self.assertFalse(mcmc._prepare) + self.assertEqual(sample, None) + + # Fifth run + x = mcmc.ask() + fx = log_pdf.evaluateS1(x)[0] + sample = mcmc.tell(fx) + self.assertTrue(sample is not None) + self.assertTrue(np.all(mcmc._groups[0][1] == x)) + self.assertEqual(mcmc._index_active_sample, 2) + self.assertTrue(np.all(mcmc._groups[0][2] == mcmc._active_sample)) + self.assertTrue(mcmc._prepare) + + # Test group transition + while mcmc._index_active_sample != 9: + x = mcmc.ask() + fx = log_pdf.evaluateS1(x)[0] + sample = mcmc.tell(fx) + + x = mcmc.ask() + self.assertTrue(np.all(mcmc._groups[0][9] == x)) + self.assertTrue(mcmc._prepare) + fx = log_pdf.evaluateS1(x)[0] + sample = mcmc.tell(fx) + self.assertEqual(None, sample) + + while sample is None: + x = mcmc.ask() + fx = log_pdf.evaluateS1(x)[0] + sample = mcmc.tell(fx) + + self.assertTrue(np.all(mcmc._groups[0][9] == sample)) + self.assertEqual(mcmc._index_active_group, 1) + self.assertEqual(mcmc._index_active_sample, 0) + self.assertTrue(np.all(mcmc._groups[1][0] == mcmc._active_sample)) + + def test_run(self): + """ + Test multiple MCMC iterations of the sampler on a + Multivariate Gaussian. + """ + # Set seed for monitoring + np.random.seed(2) + + # Create log pdf + log_pdf = pints.toy.GaussianLogPDF([2, 4], [[1, 0], [0, 3]]) + + # Create mcmc + x0 = np.array([1, 1]) + mcmc = pints.SliceGeneralisedEllipticalMCMC(x0) + + # Run multiple iterations of the sampler + chain = [] + while len(chain) < 100: + x = mcmc.ask() + fx = log_pdf.evaluateS1(x)[0] + sample = mcmc.tell(fx) + if sample is not None: + chain.append(np.copy(sample)) + + # Fit Multivariate Gaussian to chain samples + np.mean(chain, axis=0) + np.cov(chain, rowvar=0)