Gaussian Shells

Setup

First, let's set up some environmental dependencies. These just make the numerics easier and adjust some of the plotting defaults to make things more legible.


In [1]:
# Python 3 compatability
from __future__ import division, print_function
from six.moves import range

# system functions that are always useful to have
import time, sys, os

# basic numeric setup
import numpy as np
import math

# inline plotting
%matplotlib inline

# plotting
import matplotlib
from matplotlib import pyplot as plt

# seed the random number generator
np.random.seed(715)

In [2]:
# re-defining plotting defaults
from matplotlib import rcParams
rcParams.update({'xtick.major.pad': '7.0'})
rcParams.update({'xtick.major.size': '7.5'})
rcParams.update({'xtick.major.width': '1.5'})
rcParams.update({'xtick.minor.pad': '7.0'})
rcParams.update({'xtick.minor.size': '3.5'})
rcParams.update({'xtick.minor.width': '1.0'})
rcParams.update({'ytick.major.pad': '7.0'})
rcParams.update({'ytick.major.size': '7.5'})
rcParams.update({'ytick.major.width': '1.5'})
rcParams.update({'ytick.minor.pad': '7.0'})
rcParams.update({'ytick.minor.size': '3.5'})
rcParams.update({'ytick.minor.width': '1.0'})
rcParams.update({'font.size': 30})

In [3]:
import dynesty

2-D Gaussian Shells

To demonstrate more of the functionality afforded by our different sampling/bounding options we will demonstrate how these various features work using a set of 2-D Gaussian shells with a uniform prior over $[-6, 6]$.


In [4]:
# defining constants
r = 2.  # radius
w = 0.1  # width
c1 = np.array([-3.5, 0.])  # center of shell 1
c2 = np.array([3.5, 0.])  # center of shell 2
const = math.log(1. / math.sqrt(2. * math.pi * w**2))  # normalization constant

# log-likelihood of a single shell
def logcirc(theta, c):
    d = np.sqrt(np.sum((theta - c)**2, axis=-1))  # |theta - c|
    return const - (d - r)**2 / (2. * w**2)

# log-likelihood of two shells
def loglike(theta):
    return np.logaddexp(logcirc(theta, c1), logcirc(theta, c2))

# our prior transform
def prior_transform(x):
    return 12. * x - 6.

In [5]:
# compute likelihood surface over a 2-D grid
xx, yy = np.meshgrid(np.linspace(-6., 6., 200), np.linspace(-6., 6., 200))
L = np.exp(loglike(np.dstack((xx, yy))))

In [6]:
# plot result
fig = plt.figure(figsize=(6,5))
plt.scatter(xx, yy, c=L, s=0.5)
plt.xlabel(r'$x$')
plt.ylabel(r'$y$')
plt.colorbar(label=r'$\mathcal{L}$');


Default Run

Let's first run with just the default set of dynesty options.


In [7]:
# run with all defaults
sampler = dynesty.DynamicNestedSampler(loglike, prior_transform, ndim=2)
sampler.run_nested()
res = sampler.results


iter: 21158 | batch: 15 | bound: 22 | nc: 3 | ncall: 257833 | eff(%):  8.206 | loglstar: -4.860 <  1.384 <  1.255 | logz: -1.734 +/-  0.058 | stop:  0.902                                            

In [8]:
from dynesty import plotting as dyplot
dyplot.cornerplot(sampler.results, span=([-6, 6], [-6, 6]), fig=plt.subplots(2, 2, figsize=(10, 10)));


Bounding Options

Let's test out the bounding options available in dynesty (with uniform sampling) on these 2-D shells. To illustrate their baseline effectiveness, we will also disable the initial delay before our first update.


In [9]:
# bounding methods
bounds = ['none', 'single', 'multi', 'balls', 'cubes']

# run over each method and collect our results
bounds_res = []
for b in bounds:
    sampler = dynesty.NestedSampler(loglike, prior_transform, ndim=2,
                                    bound=b, sample='unif', nlive=500,
                                    first_update={'min_ncall': 0.,
                                                  'min_eff': 100.})
    sys.stderr.flush()
    sys.stderr.write('{}:\n'.format(b))
    sys.stderr.flush()
    t0 = time.time()
    sampler.run_nested(dlogz=0.05)
    t1 = time.time()
    res = sampler.results
    res['time'] = t1 - t0
    sys.stderr.flush()
    sys.stderr.write('\ntime: {0}s\n\n'.format(res['time']))
    bounds_res.append(sampler.results)


none:
iter: 3007 | +500 | bound: 1 | nc: 1 | ncall: 230040 | eff(%):  1.525 | loglstar:   -inf <  1.384 <    inf | logz: -1.603 +/-  0.069 | dlogz:  0.000 >  0.050                                         
time: 14.05546522140503s

single:
iter: 3146 | +500 | bound: 134 | nc: 1 | ncall: 113408 | eff(%):  3.215 | loglstar:   -inf <  1.384 <    inf | logz: -1.880 +/-  0.072 | dlogz:  0.000 >  0.050                                       
time: 12.799932718276978s

multi:
iter: 3053 | +500 | bound: 11 | nc: 1 | ncall: 8127 | eff(%): 43.718 | loglstar:   -inf <  1.384 <    inf | logz: -1.694 +/-  0.070 | dlogz:  0.000 >  0.050                                          
time: 5.846663236618042s

balls:
iter: 3059 | +500 | bound: 23 | nc: 1 | ncall: 17874 | eff(%): 19.912 | loglstar:   -inf <  1.384 <    inf | logz: -1.708 +/-  0.070 | dlogz:  0.000 >  0.050                                         
time: 14.162617444992065s

cubes:
iter: 3077 | +500 | bound: 25 | nc: 1 | ncall: 18961 | eff(%): 18.865 | loglstar:   -inf <  1.384 <    inf | logz: -1.744 +/-  0.071 | dlogz:  0.000 >  0.050                                         
time: 9.93903398513794s

We can see the amount of overhead associated with 'balls' and 'cubes' is non-trivial in this case. This mainly comes from sampling from our bouding distributions, since accepting or rejecting a point requires counting all neighbors within some radius $r$, leading to frequent nearest-neighbor searches. dynesty utilizes KDTrees to help with these, adding some overhead but improving overall scaling.

Runtime aside, we see that each method runs for a similar number of iterations and give similar logz values (with comparable errors). They thus appear to be unbiased both with respect to each other and with respect to the analytic solution ($\ln \mathcal{Z} = -1.75$).

To get a sense of what each of our bounds looks like, we can use some of dynesty's built-in plotting functionality. First, let's take a look at the case where we had no bounds ('none').


In [10]:
from dynesty import plotting as dyplot

# initialize figure
fig, axes = plt.subplots(1, 1, figsize=(6, 6))

# plot proposals in corner format for 'none'
fg, ax = dyplot.cornerbound(bounds_res[0], it=1500, prior_transform=prior_transform,
                            show_live=True, fig=(fig, axes))
ax[0, 0].set_title('No Bound', fontsize=26)
ax[0, 0].set_xlim([-6.5, 6.5])
ax[0, 0].set_ylim([-6.5, 6.5]);


Now let's examine the single and multi-ellipsoidal cases.


In [11]:
# initialize figure
fig, axes = plt.subplots(1, 3, figsize=(18, 6))
axes = axes.reshape((1, 3))
[a.set_frame_on(False) for a in axes[:, 1]]
[a.set_xticks([]) for a in axes[:, 1]]
[a.set_yticks([]) for a in axes[:, 1]]

# plot proposals in corner format for 'single'
fg, ax = dyplot.cornerbound(bounds_res[1], it=1500, prior_transform=prior_transform,
                            show_live=True, fig=(fig, axes[:, 0]))
ax[0, 0].set_title('Single', fontsize=26)
ax[0, 0].set_xlim([-6.5, 6.5])
ax[0, 0].set_ylim([-6.5, 6.5])

# plot proposals in corner format for 'multi'
fg, ax = dyplot.cornerbound(bounds_res[2], it=1500, prior_transform=prior_transform,
                            show_live=True, fig=(fig, axes[:, 2]))
ax[0, 0].set_title('Multi', fontsize=26)
ax[0, 0].set_xlim([-6.5, 6.5])
ax[0, 0].set_ylim([-6.5, 6.5]);


Finally, let's take a look at our overlapping set of balls and cubes.


In [12]:
# initialize figure
fig, axes = plt.subplots(1, 3, figsize=(18, 6))
axes = axes.reshape((1, 3))
[a.set_frame_on(False) for a in axes[:, 1]]
[a.set_xticks([]) for a in axes[:, 1]]
[a.set_yticks([]) for a in axes[:, 1]]

# plot proposals in corner format for 'balls'
fg, ax = dyplot.cornerbound(bounds_res[3], it=1500, prior_transform=prior_transform,
                            show_live=True, fig=(fig, axes[:, 0]))
ax[0, 0].set_title('Balls', fontsize=26)
ax[0, 0].set_xlim([-6.5, 6.5])
ax[0, 0].set_ylim([-6.5, 6.5])

# plot proposals in corner format for 'cubes'
fg, ax = dyplot.cornerbound(bounds_res[4], it=1500, prior_transform=prior_transform,
                            show_live=True, fig=(fig, axes[:, 2]))
ax[0, 0].set_title('Cubes', fontsize=26)
ax[0, 0].set_xlim([-6.5, 6.5])
ax[0, 0].set_ylim([-6.5, 6.5]);


Bounding Objects

By default, the nested samplers in dynesty save all bounding distributions used throughout the course of a run, which can be accessed within the results dictionary. More information on these distributions can be found in bounding.py.


In [13]:
# the proposals associated with our 'multi' bounds
bounds_res[2].bound


Out[13]:
[<dynesty.bounding.UnitCube at 0x7f57502e9240>,
 <dynesty.bounding.MultiEllipsoid at 0x7f57502e9320>,
 <dynesty.bounding.MultiEllipsoid at 0x7f57502e93c8>,
 <dynesty.bounding.MultiEllipsoid at 0x7f575029f2e8>,
 <dynesty.bounding.MultiEllipsoid at 0x7f575029fc18>,
 <dynesty.bounding.MultiEllipsoid at 0x7f575029f358>,
 <dynesty.bounding.MultiEllipsoid at 0x7f575025b550>,
 <dynesty.bounding.MultiEllipsoid at 0x7f5741592cc0>,
 <dynesty.bounding.MultiEllipsoid at 0x7f573f7c05f8>,
 <dynesty.bounding.MultiEllipsoid at 0x7f57412fdc18>,
 <dynesty.bounding.MultiEllipsoid at 0x7f57562d21d0>,
 <dynesty.bounding.MultiEllipsoid at 0x7f575039cbe0>]

Each bounding object has a host of additional functionality that the user can experiment with. For instance, the volume contained by the union of ellipsoids within MultiEllipsoid can be estimated using Monte Carlo integration (but otherwise are not computed by default). These volume estimates, combined with what fraction of our samples overlap with the unit cube (since our bounding distributions can exceed our prior bounds), can give us an idea of how effectively our multi-ellipsoid bounds are shrinking over time compared with the single-ellipsoid case.


In [14]:
# compute effective 'single' volumes
single_vols = [1.]  # unit cube
for bound in bounds_res[1].bound[1:]:
    vol = bound.vol  # volume
    funit = bound.unitcube_overlap() # fractional overlap with unit cube
    single_vols.append(vol * funit)
single_vols = np.array(single_vols)

# compute effective 'multi' volumes
multi_vols = [1.]  # unit cube
for bound in bounds_res[2].bound[1:]:  # skip unit cube
    vol, funit = bound.monte_carlo_vol(return_overlap=True)
    multi_vols.append(vol * funit)  # numerical estimate via Monte Carlo methods
multi_vols = np.array(multi_vols)

In [15]:
# plot results as a function of ln(volume)
plt.figure(figsize=(12,6))
plt.xlabel(r'$-\ln X_i$')
plt.ylabel(r'$\ln V_i$')

# 'single'
res = bounds_res[1]
x = -res.logvol  # ln(prior volume)
it = res.bound_iter  # proposal idx at given iteration
y = np.log(single_vols[it])  # corresponding ln(bounding volume)
plt.plot(x, y, lw=3, label='single')

# 'multi'
res = bounds_res[2]
x, it = -res.logvol, res.bound_iter
y = np.log(multi_vols[it])
plt.plot(x, y, lw=3, label='multi')
plt.legend(loc='best', fontsize=24);


We see that in the beginning, only a single ellipsoid is used. After some bounding updates have been made, there is enough of an incentive to split the proposal into several ellipsoids. Although the initial ellipsoid decompositions can be somewhat unstable (i.e. bootstrapping can give relatively large volume expansion factors), over time this process leads to a significant decrease in effective overall volume. The process by which an ellipsoid is split into multiple ellipsoids can be tuned by the user using the 'vol_dec' and 'vol_check' keyword arguments.

Sampling Options

Let's test out the sampling options available in dynesty (with 'multi' bounding) on our 2-D shells defined above.


In [16]:
# bounding methods
sampling = ['unif', 'rwalk', 'rstagger', 'slice', 'rslice', 'hslice']

# run over each method and collect our results
sampling_res = []
for s in sampling:
    sampler = dynesty.NestedSampler(loglike, prior_transform, ndim=2,
                                    bound='multi', sample=s, nlive=1000)
    sys.stderr.flush()
    sys.stderr.write('{}:\n'.format(s))
    sys.stderr.flush()
    t0 = time.time()
    sampler.run_nested(dlogz=0.05)
    t1 = time.time()
    res = sampler.results
    res['time'] = t1 - t0
    sys.stderr.flush()
    sys.stderr.write('\ntime: {0}s\n\n'.format(res['time']))
    sampling_res.append(sampler.results)


unif:
iter: 6086 | +1000 | bound: 5 | nc: 1 | ncall: 42340 | eff(%): 16.736 | loglstar:   -inf <  1.384 <    inf | logz: -1.678 +/-  0.049 | dlogz:  0.000 >  0.050                                         
time: 11.346088647842407s

rwalk:
iter: 6128 | +1000 | bound: 19 | nc: 1 | ncall: 99806 | eff(%):  7.142 | loglstar:   -inf <  1.384 <    inf | logz: -1.720 +/-  0.050 | dlogz:  0.000 >  0.050                                        
time: 13.83431601524353s

rstagger:
iter: 6187 | +1000 | bound: 19 | nc: 1 | ncall: 100452 | eff(%):  7.155 | loglstar:   -inf <  1.384 <    inf | logz: -1.779 +/-  0.050 | dlogz:  0.000 >  0.050                                       
time: 14.143909692764282s

slice:
iter: 6184 | +1000 | bound: 18 | nc: 1 | ncall: 169919 | eff(%):  4.228 | loglstar:   -inf <  1.384 <    inf | logz: -1.776 +/-  0.050 | dlogz:  0.000 >  0.050                                       
time: 16.168328762054443s

rslice:
iter: 6104 | +1000 | bound: 9 | nc: 1 | ncall: 104804 | eff(%):  6.778 | loglstar:   -inf <  1.384 <    inf | logz: -1.696 +/-  0.049 | dlogz:  0.000 >  0.050                                        
time: 12.346131563186646s

hslice:
iter: 6097 | +1000 | bound: 25 | nc: 1 | ncall: 2527256 | eff(%):  0.281 | loglstar:   -inf <  1.384 <    inf | logz: -1.689 +/-  0.049 | dlogz:  0.000 >  0.050                                      
time: 113.01402306556702s

As expected, uniform sampling in 2-D is substantially more efficient that other more complex alternatives (especially 'hslice', which is computing numerical gradients!). Regardless of runtime, however, we see that each method runs for a similar number of iterations and gives similar logz values (with comparable errors). They thus appear to be unbiased both with respect to each other and with respect to the analytic solution ($\ln\mathcal{Z} = −1.75$).

Bootstrapping

One of the largest overheads associated with nested sampling is the time needed to propose new bounding distributions. To avoid bounding distributions that fail to properly encompass the remaining likelihood, dynesty automatically expands the volume of all bounding distributions by an enlargement factor (enlarge). By default, this factor is set to a constant value of 1.25. However, it can also be determined in real time using bootstrapping (over the set of live points) following the scheme outlined in Buchner (2014).

Bootstrapping these expansion factors can help to ensure accurate evidence estimation when the proposals rely heavily on the size of an object rather than the overall shape, such as when proposing new points uniformly within their boundaries. In theory, it also helps to prevent mode "death": if occasionally a secondary mode disappears when bootstrapping, the existing bounds would be expanded to theoretically encompass it. In practice, however, most modes are widely separated, leading enormous expansion factors whenever any possible instance of mode death may occur.

Bootstrapping thus imposes a de facto floor on the number of acceptable live points to avoid mode death for any given problem, which can often be quite large for many problems. While these numbers are often justified, they can drastically reduce the raw sampling efficiency until such a target threshold of live points is reached.

We showcase this behavior below by illustrating the performance of our NestedSampler on several N-D Gaussian shells with and without bootstrapping.


In [17]:
# setup for running tests over gaussian shells in arbitrary dimensions
def run(ndim, bootstrap, bound, method, nlive):
    """Convenience function for running in any dimension."""

    c1 = np.zeros(ndim)
    c1[0] = -3.5
    c2 = np.zeros(ndim)
    c2[0] = 3.5
    f = lambda theta: np.logaddexp(logcirc(theta, c1), logcirc(theta, c2))
    sampler = dynesty.NestedSampler(f, prior_transform, ndim,
                                    bound=bound, sample=method, nlive=nlive, 
                                    bootstrap=bootstrap, 
                                    first_update={'min_ncall': 0.,
                                                  'min_eff': 100.})
    sampler.run_nested(dlogz=0.5)
    
    return sampler.results

# analytic ln(evidence) values
ndims = [2, 5, 10]
analytic_logz = {2: -1.75,
                 5: -5.67,
                 10: -14.59}

In [18]:
# results with bootstrapping
results = []
for ndim in ndims:
    t0 = time.time()
    sys.stderr.flush()
    sys.stderr.write('{} dimensions:\n'.format(ndim))
    sys.stderr.flush()
    res = run(ndim, 20, 'multi', 'unif', 2000)
    sys.stderr.flush()
    res.time = time.time() - t0
    sys.stderr.write('\ntime: {0}s\n\n'.format(res['time']))
    results.append(res)


2 dimensions:
iter: 7983 | +2000 | bound: 10 | nc: 1 | ncall: 30953 | eff(%): 32.252 | loglstar:   -inf <  1.384 <    inf | logz: -1.690 +/-  0.025 | dlogz:  0.000 >  0.500                                        
time: 43.00237488746643s

5 dimensions:
iter: 16001 | +2000 | bound: 15 | nc: 1 | ncall: 46010 | eff(%): 39.124 | loglstar:   -inf <  1.384 <    inf | logz: -5.698 +/-  0.052 | dlogz:  0.000 >  0.500                                       
time: 67.4037013053894s

10 dimensions:
iter: 33831 | +2000 | bound: 38 | nc: 1 | ncall: 113128 | eff(%): 31.673 | loglstar:   -inf <  1.384 <    inf | logz: -14.613 +/-  0.085 | dlogz:  0.000 >  0.500                                     
time: 206.95178890228271s


In [19]:
# results without bootstrapping
results2 = []
for ndim in ndims:
    t0 = time.time()
    sys.stderr.flush()
    sys.stderr.write('{} dimensions:\n'.format(ndim))
    sys.stderr.flush()
    res = run(ndim, 0, 'multi', 'unif', 2000)
    sys.stderr.flush()
    res.time = time.time() - t0
    sys.stderr.write('\ntime: {0}s\n\n'.format(res['time']))
    results2.append(res)


2 dimensions:
iter: 8076 | +2000 | bound: 9 | nc: 1 | ncall: 26774 | eff(%): 37.634 | loglstar:   -inf <  1.384 <    inf | logz: -1.737 +/-  0.026 | dlogz:  0.000 >  0.500                                         
time: 11.807486534118652s

5 dimensions:
iter: 15865 | +2000 | bound: 17 | nc: 1 | ncall: 52303 | eff(%): 34.157 | loglstar:   -inf <  1.384 <    inf | logz: -5.629 +/-  0.051 | dlogz:  0.000 >  0.500                                       
time: 22.599231243133545s

10 dimensions:
iter: 33833 | +2000 | bound: 33 | nc: 1 | ncall: 100994 | eff(%): 35.480 | loglstar:   -inf <  1.384 <    inf | logz: -14.615 +/-  0.085 | dlogz:  0.000 >  0.500                                     
time: 63.17081332206726s


In [20]:
print('With bootstrapping:')
print("D  analytic    logz  logzerr   nlike  eff(%)   time")
for ndim, res in zip(ndims, results):
    print("{:2d}   {:6.2f}  {:6.2f}     {:4.2f}  {:6d}   {:5.2f}  {:6.2f}"
          .format(ndim, analytic_logz[ndim], res.logz[-1], res.logzerr[-1],
                  sum(res.ncall), res.eff, res.time))
print('\n')
print('Without bootstrapping:')
print("D  analytic    logz  logzerr   nlike  eff(%)   time")
for ndim, res in zip(ndims, results2):
    print("{:2d}   {:6.2f}  {:6.2f}     {:4.2f}  {:6d}   {:5.2f}  {:6.2f}"
          .format(ndim, analytic_logz[ndim], res.logz[-1], res.logzerr[-1],
                  sum(res.ncall), res.eff, res.time))


With bootstrapping:
D  analytic    logz  logzerr   nlike  eff(%)   time
 2    -1.75   -1.69     0.02   30953   32.25   43.00
 5    -5.67   -5.70     0.05   46010   39.12   67.40
10   -14.59  -14.61     0.08  113128   31.67  206.95


Without bootstrapping:
D  analytic    logz  logzerr   nlike  eff(%)   time
 2    -1.75   -1.74     0.03   26774   37.63   11.81
 5    -5.67   -5.63     0.05   52303   34.16   22.60
10   -14.59  -14.61     0.08  100994   35.48   63.17

While our results are comparable between both cases, in higher dimensions multi-ellipsoid bounding distributions can sometimes be over-constrained, leading to biased results. Other sampling methods mitigate this problem by sampling conditioned on the ellipsoid axes, and so only depends on ellipsoid shapes, not sizes. 'rslice' is demonstrated below.


In [21]:
# adding on slice sampling
results3 = []
for ndim in ndims:
    t0 = time.time()
    sys.stderr.flush()
    sys.stderr.write('{} dimensions:\n'.format(ndim))
    sys.stderr.flush()
    res = run(ndim, 0, 'multi', 'rslice', 2000)
    sys.stderr.flush()
    res.time = time.time() - t0
    sys.stderr.write('\ntime: {0}s\n\n'.format(res['time']))
    results3.append(res)


2 dimensions:
iter: 8120 | +2000 | bound: 11 | nc: 1 | ncall: 215787 | eff(%):  4.690 | loglstar:   -inf <  1.384 <    inf | logz: -1.759 +/-  0.026 | dlogz:  0.000 >  0.500                                       
time: 18.313457012176514s

5 dimensions:
iter: 15801 | +2000 | bound: 21 | nc: 1 | ncall: 409706 | eff(%):  4.345 | loglstar:   -inf <  1.384 <    inf | logz: -5.597 +/-  0.051 | dlogz:  0.000 >  0.500                                      
time: 34.57554864883423s

10 dimensions:
iter: 33528 | +2000 | bound: 44 | nc: 1 | ncall: 862804 | eff(%):  4.118 | loglstar:   -inf <  1.384 <    inf | logz: -14.462 +/-  0.084 | dlogz:  0.000 >  0.500                                     
time: 74.04435849189758s


In [22]:
print('Random Slice sampling:')
print("D  analytic    logz  logzerr    nlike   eff(%)   time")
for ndim, res in zip([2, 5, 10, 20], results3):
    print("{:2d}   {:6.2f}  {:6.2f}     {:4.2f}  {:8d}   {:5.2f}  {:6.2f}"
          .format(ndim, analytic_logz[ndim], res.logz[-1], res.logzerr[-1],
                  sum(res.ncall), res.eff, res.time))


Random Slice sampling:
D  analytic    logz  logzerr    nlike   eff(%)   time
 2    -1.75   -1.76     0.03    215787    4.69   18.31
 5    -5.67   -5.60     0.05    409706    4.34   34.58
10   -14.59  -14.46     0.08    862804    4.12   74.04