Warm starting

In this tutorial you will learn:

  • How to play with model variations

  • Warm start feature: How UltraNest can resume and reuse an existing run, even if you modified the data/likelihood

As a simple example, lets say we want to fit a black body.

[1]:
import numpy as np
from numpy import pi, log
import scipy.stats
import matplotlib.pyplot as plt

Black body model

[2]:
parameters = ['Temperature', 'Amplitude']
[3]:
def black_body_model(wavelength, ampl, T):
    with np.errstate(over='ignore'):
        return ampl / wavelength**5 / (np.exp(1/(wavelength*T)) - 1)

Generate some data

[4]:
Ndata = 10
wavelength = np.logspace(1, 2, Ndata)
[5]:
np.random.seed(1)
ampl_true = 42.0
T_true = 0.01  # in um^-1
background_true = 1e-9
y_true = black_body_model(wavelength, ampl_true, T_true)
sigma_true = y_true * 0.1
y_obs = np.random.normal(y_true + background_true, sigma_true, size=Ndata)
sigma = y_true * 0.1

Visualise the data

Lets plot the data first to see what is going on:

[6]:
plt.figure(figsize=(10, 5))
plt.errorbar(x=wavelength, y=y_obs, yerr=sigma, marker='x', ls=' ')
plt.plot(wavelength, y_true, ':', color='gray')
plt.ylabel('Spectral flux density [Jy]');
plt.xlabel(r'Wavelength [$\mu$m]');

_images/example-warmstart_9_0.svg

Prior

Here we intentionally set very wide priors:

  • a uniform prior on temperature, and

  • a very wide log-uniform prior on the normalisation.

[7]:
def prior_transform(x):
    z = x.copy()
    z[0] = x[0]
    z[1] = 10**(x[1] * 20 - 10)
    return z

[8]:
plt.figure(figsize=(10, 5))
plt.title("Prior predictive checks")
plt.errorbar(x=wavelength, y=y_obs, yerr=sigma, marker='x', ls=' ')
plt.ylim(0, y_obs.max() * 10)

for i in range(20):
    T, ampl = prior_transform(np.random.uniform(size=len(parameters)))
    y_predicted = black_body_model(wavelength, ampl, T)
    plt.plot(wavelength, y_predicted, '-', color='gray')
plt.ylabel('Spectral flux density [Jy]');
plt.xlabel('Wavelength [$\\mu$m]');

_images/example-warmstart_13_0.svg

First simple model

Here is a typical gaussian likelihood with our black body function:

[9]:
def log_likelihood(params):
    T, ampl = params
    y_predicted = black_body_model(wavelength, ampl, T)
    return scipy.stats.norm(y_predicted, sigma).logpdf(y_obs).sum()

[10]:
from ultranest import ReactiveNestedSampler

reference_run_folder = 'blackbody-alldata'
sampler_ref = ReactiveNestedSampler(parameters, log_likelihood, prior_transform, log_dir=reference_run_folder, resume='overwrite')
results_ref = sampler_ref.run(frac_remain=0.5)
sampler_ref.print_results()

[ultranest] Sampling 400 live points from prior ...
[ultranest] Explored until L=2e+02
[ultranest] Likelihood function evaluations: 46953
[ultranest] Writing samples and results to disk ...
[ultranest] Writing samples and results to disk ... done
[ultranest]   logZ = 159.8 +- 0.1527
[ultranest] Effective samples strategy satisfied (ESS = 981.1, need >400)
[ultranest] Posterior uncertainty strategy is satisfied (KL: 0.46+-0.06 nat, need <0.50 nat)
[ultranest] Evidency uncertainty strategy is satisfied (dlogz=0.43, need <0.5)
[ultranest]   logZ error budget: single: 0.19 bs:0.15 tail:0.41 total:0.43 required:<0.50
[ultranest] done iterating.

logZ = 159.705 +- 0.538
  single instance: logZ = 159.705 +- 0.186
  bootstrapped   : logZ = 159.775 +- 0.353
  tail           : logZ = +- 0.406
insert order U test : converged: True correlation: inf iterations

    Temperature         : 0.00945│ ▁▁▁▁▁▁▁▁▂▂▂▂▄▅▄▅▅▇▇▅▆▅▅▃▃▂▂▂▁▁▁▁▁▁▁ ▁ │0.01035    0.00989 +- 0.00012
    Amplitude           : 37.2  │ ▁▁▁▁▁▁▁▂▂▃▃▅▄▅▆▆▇▇▆▆▄▄▃▃▂▂▁▁▁▁▁▁▁▁▁▁▁ │59.7      47.4 +- 2.7

Plot the fit

[11]:
plt.figure(figsize=(10, 5))
plt.errorbar(x=wavelength, y=y_obs, yerr=sigma, marker='x', ls=' ')
from ultranest.plot import PredictionBand
band = PredictionBand(wavelength)
for T, ampl in results_ref['samples']:
    band.add(black_body_model(wavelength, ampl, T))
band.line(color='k')
band.shade(color='k', alpha=0.5)
plt.ylabel('Spectral flux density [Jy]');
plt.xlabel('Wavelength [$\\mu$m]');

_images/example-warmstart_18_0.svg

Warm starting a modified model

Lets say we alter our model slightly. We include a small constant background:

[12]:
def log_likelihood_with_background(params):
    T, ampl = params
    y_predicted = black_body_model(wavelength, ampl, T) + 1e-9
    return scipy.stats.norm(y_predicted, sigma).logpdf(y_obs).sum()

We have the same parameters, and expect results to be only mildly different. So lets use warm starting.

Using the previous reference run output file …

[13]:
posterior_upoints_file = reference_run_folder + '/chains/weighted_post_untransformed.txt'

We define our accelerated likelihood and prior transform:

[14]:
from ultranest.integrator import warmstart_from_similar_file

aux_paramnames, aux_log_likelihood, aux_prior_transform, vectorized = warmstart_from_similar_file(
    posterior_upoints_file, parameters, log_likelihood_with_background, prior_transform)

Make accelerated run:

[15]:
sampler = ReactiveNestedSampler(aux_paramnames, aux_log_likelihood, aux_prior_transform, vectorized=vectorized)
res = sampler.run(frac_remain=0.5)
[ultranest] Sampling 400 live points from prior ...
[ultranest] Explored until L=2e+02
[ultranest] Likelihood function evaluations: 5393
[ultranest]   logZ = 166.7 +- 0.0473
[ultranest] Effective samples strategy satisfied (ESS = 763.2, need >400)
[ultranest] Posterior uncertainty strategy is satisfied (KL: 0.45+-0.08 nat, need <0.50 nat)
[ultranest] Evidency uncertainty strategy is satisfied (dlogz=0.41, need <0.5)
[ultranest]   logZ error budget: single: 0.08 bs:0.05 tail:0.41 total:0.41 required:<0.50
[ultranest] done iterating.
[16]:
plt.figure(figsize=(10, 5))
plt.errorbar(x=wavelength, y=y_obs, yerr=sigma, marker='x', ls=' ')
from ultranest.plot import PredictionBand
band = PredictionBand(wavelength)
for T, ampl in results_ref['samples']:
    band.add(black_body_model(wavelength, ampl, T))
band.line(color='k')
band.shade(color='k', alpha=0.5)

band = PredictionBand(wavelength)
for T, ampl, _ in res['samples']:
    band.add(black_body_model(wavelength, ampl, T))
band.line(color='orange')
band.shade(color='orange', alpha=0.5)
plt.plot(wavelength, y_true, ':', color='gray')
plt.ylabel('Spectral flux density [Jy]');
plt.xlabel('Wavelength [$\\mu$m]');

_images/example-warmstart_28_0.svg

Speed-up

[17]:
print("Speed-up of warm-start: %d%%" % ((results_ref['ncall'] / res['ncall'] - 1)*100))
Speed-up of warm-start: 770%

The cost savings are higher, the more similar the posterior of the modified run is to the original run. This speed-up increases drastically if you have highly informative posteriors. This benefit is independent of problem dimension.

How it works & Limitations

Warm-starting works by deforming the parameter space. The prior transform function is adjusted, and the adjustment is removed by reweighting the likelihood function, to produce the same posterior. To make this work, posterior samples from the unit cube space are required. The deformation uses a factorized auxiliary distribution, based on marginal posterior quantiles.

The weighted_post_untransformed.txt file from a hot-started run cannot be used. This is because it has a deformation already applied.

Read the full documentation at * warmstart_from_similar_file and * the underlying get_auxiliary_contbox_parameterization function

Warm starting from posterior samples

If you already have posterior samples, then you can create an appropriate weighted_post_untransformed.txt file. However, the inverse of the prior transformation has to be applied.

In some cases, this is easy to do analytically, e.g., for uniform priors it is just a scaling.

When the transform cannot be inverted easily

The following code works for arbitrary, factorized priors (as in the blackbody example in this notebook), for an arbitrary number of parameters.

Lets start with our posterior samples. These could be obtained posterior samples from MCMC, or generated from the parameter errors quoted in a paper. Here we take it from the reference run:

[18]:
posterior_samples = results_ref['samples']

plt.scatter(posterior_samples[:,0], posterior_samples[:,1]);
plt.xlabel('%s (p-space)' % parameters[0])
plt.ylabel('%s (p-space)' % parameters[1]);

_images/example-warmstart_36_0.svg

Lets have a look how our unit-cube prior transform works:

The first parameter has a uniform prior, the other a log-uniform prior.

[19]:
uguess = np.linspace(1e-6, 1-1e-6, 40000)
pguess = np.array([prior_transform(ui * np.ones(len(parameters))) for ui in uguess])
[20]:
plt.subplot(2, 1, 1)
plt.plot(uguess, pguess[:,0])
plt.xlabel('u-space (%s)' % parameters[0])
plt.ylabel('p-space (%s)' % parameters[0]);
plt.subplot(2, 1, 2)
plt.plot(uguess, pguess[:,1])
plt.xlabel('u-space (%s)' % parameters[1])
plt.ylabel('p-space (%s)' % parameters[1]);
_images/example-warmstart_39_0.svg

Here we convert the posterior samples to u-space, by finding the unit-cube value by optimization.

[21]:
import scipy.optimize
[22]:
import tqdm
[23]:
nparams = len(parameters)
u = np.ones(nparams) * 0.5
stdevs = posterior_samples.std(axis=0)

def minfunc(ui, i, u, pi):
    if not 0 < ui < 1: return 1e100
    u[i] = ui
    p = prior_transform(u)
    return (p[i] - pi)**2

usamples = np.empty((len(posterior_samples), nparams))
for j, sample in enumerate(tqdm.tqdm(posterior_samples)):
    for i, param in enumerate(parameters):
        ui0 = np.interp(sample[i], pguess[:,i], uguess)
        result = scipy.optimize.minimize_scalar(
            minfunc,
            args=(i, u, sample[i]),
            method='brent',
            bracket=(ui0 - 1e-4, ui0, ui0 + 1e-4),
            tol=0.001 * stdevs[i],
        )
        usamples[j,i] = result.x
100%|████████████████████████████████████████████████████████████████████████████████████████████████████████| 6505/6505 [00:00<00:00, 11398.74it/s]

Lets see whether our untransformed (u-space) posterior samples are correct:

[24]:
weights = results_ref['weighted_samples']['weights']
i = np.random.choice(len(weights), p=weights, size=1000)
plt.scatter(results_ref['weighted_samples']['upoints'][i,0], results_ref['weighted_samples']['upoints'][i,1],
            color='gray', label='reference run');

plt.scatter(usamples[:,0], usamples[:,1], label='modified run, usamples reconstructed', marker='x', alpha=0.5)
plt.xlabel('u-space (%s)' % parameters[0])
plt.ylabel('u-space (%s)' % parameters[1])
plt.legend();
_images/example-warmstart_45_0.svg

This looks like great agreement! We successfully untransformed the posterior samples to u-space.

Writing a run file for warm start

We write a weighted_post_untransformed.txt file based on our untransformed posterior samples. Since these are equally weighted, the first two columns are constants.

[25]:
weights = np.ones((len(usamples), 1)) / len(usamples)
logl = np.zeros(len(usamples)).reshape((-1, 1))

np.savetxt(
    'custom-weighted_post_untransformed.txt',
    np.hstack((weights, logl, usamples)),
    header=' '.join(['weight', 'logl'] + parameters),
    fmt='%f'
)
[26]:
!head custom-weighted_post_untransformed.txt
# weight logl Temperature Amplitude
0.000154 0.000000 0.009828 0.584218
0.000154 0.000000 0.009933 0.582950
0.000154 0.000000 0.009911 0.583998
0.000154 0.000000 0.010134 0.580971
0.000154 0.000000 0.009717 0.585696
0.000154 0.000000 0.009823 0.584407
0.000154 0.000000 0.009729 0.585907
0.000154 0.000000 0.009938 0.582713
0.000154 0.000000 0.009849 0.582950

We can now point the warmstart_from_similar_file function at this file.

Conclusion

Warm start allows accelerated computation based on already knowing the posterior peak approximately. This allows you to:

  • vary the data (change the analysis pipeline)

  • vary model assumptions

without needing to start the computation from scratch (potentially costly).

These features are experimental and feedback is appreciated. It is recommended to do a full, clean run to obtain final, reliable results before publication.

References: * “SuperNest” by Aleksandr Petrosyan and Will Handley https://arxiv.org/abs/2212.01760