From 142dce5194bec95797c9ec7b443c3be5d09acc27 Mon Sep 17 00:00:00 2001 From: Henri Vuollekoski Date: Fri, 19 May 2017 17:00:38 +0300 Subject: [PATCH 01/38] Update README.md --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 879f3a47..0dd07939 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,6 @@ +**Version 0.5 released!** This introduces small but significant changes in syntax. See the CHANGELOG and [notebooks](https://github.com/elfi-dev/notebooks). + + ELFI - Engine for Likelihood-Free Inference =========================================== @@ -18,8 +21,6 @@ Other names or related approaches to likelihood-free inference include Approxima Bayesian Computation ([ABC](https://en.wikipedia.org/wiki/Approximate_Bayesian_computation)), simulator-based inference, approximative Bayesian inference, indirect inference, etc. -**Note:** Versions 0.5+ introduce small but significant changes in syntax. See the [notebooks](https://github.com/elfi-dev/notebooks). - Currently implemented ABC methods: - ABC Rejection sampler - Sequential Monte Carlo ABC sampler From 645ab0e59f1a3c65bbcf1e5a2839477430115df6 Mon Sep 17 00:00:00 2001 From: Henri Vuollekoski Date: Fri, 19 May 2017 17:03:31 +0300 Subject: [PATCH 02/38] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 0dd07939..53992525 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -**Version 0.5 released!** This introduces small but significant changes in syntax. See the CHANGELOG and [notebooks](https://github.com/elfi-dev/notebooks). +**Version 0.5 released!** This introduces many new features and small but significant changes in syntax. See the CHANGELOG and [notebooks](https://github.com/elfi-dev/notebooks). ELFI - Engine for Likelihood-Free Inference From 91a7495943fb591a595d871c25e5e746d968cb51 Mon Sep 17 00:00:00 2001 From: Jarno Lintusaari Date: Wed, 24 May 2017 14:24:27 +0300 Subject: [PATCH 03/38] Add priors to BOLFI GP hyperparameters (#164) --- elfi/bo/acquisition.py | 224 +---------------------------- elfi/bo/gpy_regression.py | 27 ++-- tests/functional/test_inference.py | 50 ++----- tests/unit/test_bo.py | 40 ++++++ 4 files changed, 64 insertions(+), 277 deletions(-) create mode 100644 tests/unit/test_bo.py diff --git a/elfi/bo/acquisition.py b/elfi/bo/acquisition.py index 40b526c5..97ed396a 100644 --- a/elfi/bo/acquisition.py +++ b/elfi/bo/acquisition.py @@ -13,6 +13,7 @@ # TODO: make a faster optimization method utilizing parallelization (see e.g. GPyOpt) # TODO: make use of random_state + class AcquisitionBase: """All acquisition functions are assumed to fulfill this interface. @@ -37,7 +38,6 @@ def __init__(self, model, max_opt_iter=1000, noise_cov=0.): noise_cov = np.eye(self.model.input_dim) * noise_cov self.noise_cov = noise_cov - def evaluate(self, x, t=None): """Evaluates the acquisition function value at 'x' @@ -108,7 +108,7 @@ class LCBSC(AcquisitionBase): Notes ----- The formula presented in Brochu (pp. 15) seems to be from Srinivas et al. Theorem 2. - However, instead of having t**(2/d + 2) in \beta_t, it seems that the correct form + However, instead of having t**(d/2 + 2) in \beta_t, it seems that the correct form would be t**(2d + 2). """ @@ -139,223 +139,3 @@ def acquire(self, n_values, pending_locations=None, t=None): bounds = np.stack(self.model.bounds) return uniform(bounds[:,0], bounds[:,1] - bounds[:,0])\ .rvs(size=(n_values, self.model.input_dim)) - - -# TODO: below need to be refactored - - -class AcquisitionSequence(AcquisitionBase): - """A sequence of acquisition functions. - - Parameters - ---------- - schedule : list of AcquisitionBase objects - """ - - def __init__(self, schedule=None): - if schedule is None or len(schedule) < 1: - raise ValueError("Schedule must contain at least one element.") - self.schedule = schedule - self._check_schedule() - - def _check_schedule(self): - """Raises an error if schedule is not valid. - - All acquisition functions should inherit AcquisitionBase. - All acquisition functions should share the same model. - All acquisition functions in the schedule should be reachable. - """ - model = self.schedule[0].model - at_end = False - for acq in self.schedule: - if not isinstance(acq, AcquisitionBase): - raise ValueError("Only AcquisitionBase objects can be added to the schedule.") - if acq.model != model: - raise ValueError("All acquisition functions should have same model.") - if at_end is True: - raise ValueError("Unreachable acquisition function at the end of list.") - if acq.n_samples is None: - at_end = True - - def __add__(self, acquisition): - """Appends an acquisition function to the schedule. - """ - self.schedule.append(acquisition) - self._check_schedule() - return self - - def _get_next(self): - """Returns next acquisition function in schedule. - """ - for acq in self.schedule: - if not acq.finished: - return acq - return None - - @property - def n_samples(self): - n_samples = 0 - for m in self.schedule: - if m.n_samples is None: - continue - n_samples = m.n_samples - - return n_samples or sys.maxsize - - @property - def n_acquired(self): - n_acquired = 0 - for m in self.schedule: - n_acquired = m.n_acquired - - return n_acquired or sys.maxsize - - def acquire(self, n_values, pending_locations=None, t=None): - """Returns the next batch of acquisition points. - - Parameters - ---------- - n_values : int - Number of values to return. - pending_locations : None or numpy 2d array - If given, asycnhronous acquisition functions may - use the locations in choosing the next sampling - location. Locations should be in rows. - - Returns - ------- - Return is of type numpy.array_2d, locations on rows. - """ - - if n_values == 0: - return np.empty(shape=(0,0)) - - acq = self._get_next() - if acq is None: - raise IndexError("No more acquisition functions in schedule") - if n_values > acq.samples_left: - raise NotImplementedError("Acquisition function number of samples must be " - "multiple of n_values. Dividing a batch to multiple acquisition " - "functions is not yet implemented.") - return acq.acquire(n_values, pending_locations, t) - - @property - def samples_left(self): - """ Return number of samples left to sample or sys.maxsize if no limit """ - s_left = 0 - for acq in self.schedule: - if acq.n_samples is not None: - s_left += acq.samples_left - else: - return sys.maxsize - return s_left - - @property - def finished(self): - """ Returns False if number of acquired samples is less than - number of total samples - """ - return self.samples_left < 1 - - -class LCB(AcquisitionBase): - - def __init__(self, *args, exploration_rate=2.0, opt_iterations=1000, **kwargs): - self.exploration_rate = float(exploration_rate) - self.opt_iterations = int(opt_iterations) - super(LCB, self).__init__(*args, **kwargs) - - def _eval(self, x): - """ Lower confidence bound = mean - k * std """ - y_m, y_s2, y_s = self.model.predict(x) - return float(y_m - self.exploration_rate * y_s) - - def acquire(self, n_values, pending_locations=None): - ret = super(LCB, self).acquire(n_values, pending_locations) - minloc, val = stochastic_optimization(self._eval, self.model.bounds, self.opt_iterations) - for i in range(self.n_values): - ret[i] = minloc - return ret - - -class RandomAcquisition(AcquisitionBase): - """Acquisition purely from priors. This can be useful if parameters - in certain regions are forbidden (i.e. their pdf is zero). - - Parameters - ---------- - prior_list : list of Prior objects - - """ - - def __init__(self, prior_list, *args, **kwargs): - self.prior_list = prior_list - n_priors = len(prior_list) - - # hacky... - class DummyModel(object): - pass - model = DummyModel() - model.input_dim = n_priors - model.bounds = tuple(zip([0]*n_priors, [1]*n_priors)) - model.evaluate = lambda x : exec('raise NotImplementedError') - - super(RandomAcquisition, self).__init__(*args, model=model, **kwargs) - - def acquire(self, n_values, pending_locations=None): - ret = super(RandomAcquisition, self).acquire(n_values, pending_locations) - for i, p in enumerate(self.prior_list): - ret[:, i] = p.generate(n_values).compute().ravel() - logger.debug("Acquired {}".format(n_values)) - return ret - - -class RbfAtPendingPointsMixin(AcquisitionBase): - """ Adds RBF kernels at pending point locations """ - - def __init__(self, *args, rbf_scale=1.0, rbf_amplitude=1.0, **kwargs): - self.rbf_scale = rbf_scale - self.rbf_amplitude = rbf_amplitude - super(RbfAtPendingPointsMixin, self).__init__(*args, **kwargs) - - def _eval(self, x): - val = super(RbfAtPendingPointsMixin, self)._eval(x) - if self.pending_locations is None or self.pending_locations.shape[0] < 1: - return val - val += sum_of_rbf_kernels(x, self.pending_locations, self.rbf_amplitude, - self.rbf_scale) - return val - - -class SecondDerivativeNoiseMixin(AcquisitionBase): - - def __init__(self, *args, second_derivative_delta=0.01, **kwargs): - self.second_derivative_delta = second_derivative_delta - super(SecondDerivativeNoiseMixin, self).__init__(*args, **kwargs) - - def acquire(self, n_values, pending_locations=None): - """ Adds noise based on function second derivative """ - opts = super(SecondDerivativeNoiseMixin, self).acquire(n_values, pending_locations) - locs = list() - for i in range(n_values): - opt = opts[i] - loc = list() - for dim, val in enumerate(opt.tolist()): - d2 = approx_second_partial_derivative(self._eval, opt, dim, - self.second_derivative_delta, self.model.bounds) - # std from matching second derivative to that of normal - # -N(0,std)'' = 1/(sqrt(2pi)std^3) = der2 - # => std = der2 ** -1/3 * (2*pi) ** -1/6 - if d2 > 0: - std = np.power(2*np.pi, -1.0/6.0) * np.power(d2, -1.0/3.0) - else: - std = float("inf") - low = self.model.bounds[dim][0] - high = self.model.bounds[dim][1] - maxstd = (high - low) / 2.0 - std = min(std, maxstd) # limit noise amount based on bounds - a, b = (low - val) / std, (high - val) / std # standard bounds - newval = truncnorm.rvs(a, b, loc=val, scale=std) - loc.append(newval) - locs.append(loc) - return np.atleast_2d(locs) diff --git a/elfi/bo/gpy_regression.py b/elfi/bo/gpy_regression.py index cd11b186..11269493 100644 --- a/elfi/bo/gpy_regression.py +++ b/elfi/bo/gpy_regression.py @@ -1,4 +1,3 @@ -# TODO: rename file to GPyRegression # TODO: make own general GPRegression and kernel classes import logging @@ -197,27 +196,25 @@ def _init_gp(self, x, y): mean_function=mean_function) def _default_kernel(self, x, y): - # some heuristics to choose kernel parameters based on the initial data - length_scale = (np.max(x) - np.min(x)) / 3. + # Some heuristics to choose kernel parameters based on the initial data + length_scale = (np.max(self.bounds) - np.min(self.bounds)) / 3. kernel_var = (np.max(y) / 3.)**2. bias_var = kernel_var / 4. - # avoid unintentional initialization to very small length_scale (especially 0) - if length_scale < 1e-6: - length_scale = 1. - - # TODO: Priors - # kern.lengthscale.set_prior(GPy.priors.Gamma.from_EV(1.,100.), warning=False) - # kern.variance.set_prior(GPy.priors.Gamma.from_EV(1.,100.), warning=False) - # likelihood.variance.set_prior(GPy.priors.Gamma.from_EV(1.,100.), warning=False) - # Construct a default kernel - kernel = GPy.kern.RBF(input_dim=self.input_dim, variance=kernel_var, - lengthscale=length_scale) + kernel = GPy.kern.RBF(input_dim=self.input_dim) + + # Set the priors + kernel.lengthscale.set_prior( + GPy.priors.Gamma.from_EV(length_scale, length_scale), warning=False) + kernel.variance.set_prior( + GPy.priors.Gamma.from_EV(kernel_var, kernel_var), warning=False) # If no mean function is specified, add a bias term to the kernel if 'mean_function' not in self.gp_params: - kernel += GPy.kern.Bias(input_dim=self.input_dim, variance=bias_var) + bias = GPy.kern.Bias(input_dim=self.input_dim) + bias.set_prior(GPy.priors.Gamma.from_EV(bias_var, bias_var), warning=False) + kernel += bias return kernel diff --git a/tests/functional/test_inference.py b/tests/functional/test_inference.py index e510b626..f44d14c7 100644 --- a/tests/functional/test_inference.py +++ b/tests/functional/test_inference.py @@ -14,6 +14,13 @@ ) +""" +This file tests inference methods point estimates with an informative data from the +MA2 process. +""" + + + def setup_ma2_with_informative_data(): true_params = OrderedDict([('t1', .6), ('t2', .2)]) n_obs = 100 @@ -88,47 +95,9 @@ def test_smc(): assert res.populations[-1].n_batches < 6 -@pytest.mark.usefixtures('with_all_clients') -def test_BO(): - m, true_params = setup_ma2_with_informative_data() - - # Log discrepancy tends to work better - log_d = NodeReference(m['d'], state=dict(_operation=np.log), model=m, name='log_d') - - n_init = 20 - res_init = elfi.Rejection(log_d, batch_size=5).sample(n_init, quantile=1) - - bo = elfi.BayesianOptimization(log_d, initial_evidence=res_init.outputs, update_interval=10, batch_size=5, - bounds=[(-2,2)]*len(m.parameters)) - assert bo.target_model.n_evidence == n_init - assert bo.n_evidence == n_init - assert bo._n_precomputed == n_init - assert bo.n_initial_evidence == n_init - - n1 = 5 - res = bo.infer(n_init + n1) - - assert bo.target_model.n_evidence == n_init + n1 - assert bo.n_evidence == n_init + n1 - assert bo._n_precomputed == n_init - assert bo.n_initial_evidence == n_init - - n2 = 5 - res = bo.infer(n_init + n1 + n2) - - assert bo.target_model.n_evidence == n_init + n1 + n2 - assert bo.n_evidence == n_init + n1 + n2 - assert bo._n_precomputed == n_init - assert bo.n_initial_evidence == n_init - - assert np.array_equal(bo.target_model._gp.X[:n_init, 0], res_init.samples_list[0]) - - @slow @pytest.mark.usefixtures('with_all_clients') def test_BOLFI(): - # logging.basicConfig(level=logging.DEBUG) - # logging.getLogger('elfi.executor').setLevel(logging.WARNING) m, true_params = setup_ma2_with_informative_data() @@ -140,8 +109,9 @@ def test_BOLFI(): res = bolfi.infer(300) assert bolfi.target_model.n_evidence == 300 acq_x = bolfi.target_model._gp.X + # check_inference_with_informative_data(res, 1, true_params, error_bound=.2) - assert np.abs(res['samples']['t1'] - true_params['t1']) < 0.2 + assert np.abs(res['samples']['t1'] - true_params['t1']) < 0.15 assert np.abs(res['samples']['t2'] - true_params['t2']) < 0.2 # Test that you can continue the inference where we left off @@ -158,7 +128,7 @@ def test_BOLFI(): vals_map = dict(t1=np.array([post_map[0]]), t2=np.array([post_map[1]])) check_inference_with_informative_data(vals_map, 1, true_params, error_bound=.2) - # TODO: this is very, very slow in Travis??? + # Commented out because for some reason, this is very, very slow in Travis # n_samples = 100 # n_chains = 4 # res_sampling = bolfi.sample(n_samples, n_chains=n_chains) diff --git a/tests/unit/test_bo.py b/tests/unit/test_bo.py new file mode 100644 index 00000000..c505c64b --- /dev/null +++ b/tests/unit/test_bo.py @@ -0,0 +1,40 @@ +import pytest + +import numpy as np + +import elfi + + +@pytest.mark.usefixtures('with_all_clients') +def test_BO(ma2): + # Log transform of the distance usually smooths the distance surface + log_d = elfi.Operation(np.log, ma2['d'], name='log_d') + + n_init = 20 + res_init = elfi.Rejection(log_d, batch_size=5).sample(n_init, quantile=1) + + bo = elfi.BayesianOptimization(log_d, initial_evidence=res_init.outputs, + update_interval=10, batch_size=5, + bounds=[(-2,2)]*len(ma2.parameters)) + assert bo.target_model.n_evidence == n_init + assert bo.n_evidence == n_init + assert bo._n_precomputed == n_init + assert bo.n_initial_evidence == n_init + + n1 = 5 + bo.infer(n_init + n1) + + assert bo.target_model.n_evidence == n_init + n1 + assert bo.n_evidence == n_init + n1 + assert bo._n_precomputed == n_init + assert bo.n_initial_evidence == n_init + + n2 = 5 + bo.infer(n_init + n1 + n2) + + assert bo.target_model.n_evidence == n_init + n1 + n2 + assert bo.n_evidence == n_init + n1 + n2 + assert bo._n_precomputed == n_init + assert bo.n_initial_evidence == n_init + + assert np.array_equal(bo.target_model._gp.X[:n_init, 0], res_init.samples_list[0]) From c9ea5edbb55b029f63c6fc8b4e266d7b69e57cf9 Mon Sep 17 00:00:00 2001 From: Jarno Lintusaari Date: Thu, 1 Jun 2017 17:44:54 +0300 Subject: [PATCH 04/38] New documentation (#166) * New doc structure * Documentation updates - Added Python 3.6. to test suites - Edited GitHub readme.md - Modified installation documentation * Docs from notebooks (WIP) * Exporting notebooks to documentation * API documentation * Updated file structure * Rerun the tutorial * Added API to docs * Updated image URLS outside from the repo * Remove image files from the repo * Front page image url fix --- .gitignore | 6 + .travis.yml | 2 + CHANGELOG.md => CHANGELOG.rst | 7 +- Makefile | 25 +- README.md | 86 +- docs/api.rst | 122 ++ docs/conf.py | 116 +- docs/contributing.rst | 1 - docs/description.rst | 7 + docs/developer/architecture.rst | 2 + docs/developer/contributing.rst | 1 + docs/developer/extensions.rst | 2 + docs/elfi.bo.rst | 38 - docs/elfi.clients.rst | 30 - docs/elfi.methods.rst | 30 - docs/elfi.model.rst | 30 - docs/elfi.results.rst | 22 - docs/elfi.rst | 90 -- docs/elfi.visualization.rst | 30 - docs/faq.rst | 4 + docs/index.rst | 80 +- docs/installation.rst | 60 +- docs/make.bat | 281 ---- docs/modules.rst | 7 - docs/quickstart.rst | 132 ++ docs/readme.rst | 39 - docs/source/elfi.bo.rst | 38 - docs/source/elfi.clients.rst | 30 - docs/source/elfi.methods.rst | 38 - docs/source/elfi.model.rst | 46 - docs/source/elfi.results.rst | 22 - docs/source/elfi.rst | 90 -- docs/source/elfi.visualization.rst | 30 - docs/source/modules.rst | 7 - docs/usage.rst | 12 - docs/usage/external.rst | 631 +++++++++ docs/usage/implementing-methods.rst | 2 + docs/usage/parallelization.rst | 229 ++++ docs/usage/tutorial.rst | 1128 +++++++++++++++++ elfi/__init__.py | 6 +- elfi/graphical_model.py | 3 +- elfi/{ => methods}/bo/__init__.py | 0 elfi/{ => methods}/bo/acquisition.py | 9 +- elfi/{ => methods}/bo/gpy_regression.py | 0 elfi/{ => methods}/bo/utils.py | 0 elfi/{ => methods}/mcmc.py | 0 elfi/methods/methods.py | 83 +- elfi/methods/posteriors.py | 212 ---- .../{results/result.py => methods/results.py} | 218 +++- elfi/model/elfi_model.py | 172 ++- elfi/results/__init__.py | 0 requirements-dev.txt | 3 +- setup.py | 5 +- tests/functional/test_inference.py | 2 +- tests/unit/test_elfi_model.py | 2 +- tests/unit/test_mcmc.py | 5 +- tests/unit/test_results.py | 5 +- tests/unit/test_utils.py | 4 +- 58 files changed, 2807 insertions(+), 1475 deletions(-) rename CHANGELOG.md => CHANGELOG.rst (97%) create mode 100644 docs/api.rst delete mode 100644 docs/contributing.rst create mode 100644 docs/description.rst create mode 100644 docs/developer/architecture.rst create mode 100644 docs/developer/contributing.rst create mode 100644 docs/developer/extensions.rst delete mode 100644 docs/elfi.bo.rst delete mode 100644 docs/elfi.clients.rst delete mode 100644 docs/elfi.methods.rst delete mode 100644 docs/elfi.model.rst delete mode 100644 docs/elfi.results.rst delete mode 100644 docs/elfi.rst delete mode 100644 docs/elfi.visualization.rst create mode 100644 docs/faq.rst delete mode 100644 docs/make.bat delete mode 100644 docs/modules.rst create mode 100644 docs/quickstart.rst delete mode 100644 docs/readme.rst delete mode 100644 docs/source/elfi.bo.rst delete mode 100644 docs/source/elfi.clients.rst delete mode 100644 docs/source/elfi.methods.rst delete mode 100644 docs/source/elfi.model.rst delete mode 100644 docs/source/elfi.results.rst delete mode 100644 docs/source/elfi.rst delete mode 100644 docs/source/elfi.visualization.rst delete mode 100644 docs/source/modules.rst delete mode 100644 docs/usage.rst create mode 100644 docs/usage/external.rst create mode 100644 docs/usage/implementing-methods.rst create mode 100644 docs/usage/parallelization.rst create mode 100644 docs/usage/tutorial.rst rename elfi/{ => methods}/bo/__init__.py (100%) rename elfi/{ => methods}/bo/acquisition.py (96%) rename elfi/{ => methods}/bo/gpy_regression.py (100%) rename elfi/{ => methods}/bo/utils.py (100%) rename elfi/{ => methods}/mcmc.py (100%) delete mode 100644 elfi/methods/posteriors.py rename elfi/{results/result.py => methods/results.py} (57%) delete mode 100644 elfi/results/__init__.py diff --git a/.gitignore b/.gitignore index d3e20a6b..23b5eaa7 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,12 @@ var/ .installed.cfg *.egg +# Images +.png +.svg +.jpg +.jpeg + # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. diff --git a/.travis.yml b/.travis.yml index cb82c4ca..3baf91bf 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,8 @@ language: python python: - "3.5" + - "3.6" + cache: pip # command to install dependencies install: diff --git a/CHANGELOG.md b/CHANGELOG.rst similarity index 97% rename from CHANGELOG.md rename to CHANGELOG.rst index 43d6f60f..56b9a6f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.rst @@ -1,10 +1,13 @@ -# Change Log +Change Log +========== 0.5 (2017-05-19) ---------------- + Major update, a lot of code base rewritten. Most important changes: + - revised syntax for model definition (esp. naming) - scheduler-independent parallelization interface (currently supports native & ipyparallel) - methods can now be run iteratively @@ -19,12 +22,14 @@ See the updated notebooks and documentation for examples and details. 0.3.1 (2017-01-31) ------------------ + - Clean up requirements - Set graphviz and unqlite optional - PyPI release (pip install elfi) 0.2.2 - 0.3 ----------- + - The inference problem is now contained in an Inference Task object. - SMC-ABC has been reimplemented. - Results from inference are now contained in a Result object. diff --git a/Makefile b/Makefile index 77d9ee10..59018e1b 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: clean clean-test clean-pyc clean-build docs help +.PHONY: clean clean-test clean-pyc clean-build docs notebook-docs help .DEFAULT_GOAL := help define BROWSER_PYSCRIPT import os, webbrowser, sys @@ -64,16 +64,25 @@ coverage: ## check code coverage quickly with the default Python $(BROWSER) htmlcov/index.html docs: ## generate Sphinx HTML documentation, including API docs - rm -f docs/elfi.rst - rm -f docs/elfi.bo.rst - rm -f docs/modules.rst - sphinx-apidoc -o docs/ elfi $(MAKE) -C docs clean + $(MAKE) notebook-docs $(MAKE) -C docs html - $(BROWSER) docs/_build/html/index.html + # $(BROWSER) docs/_build/html/index.html -servedocs: docs ## compile the docs watching for changes - watchmedo shell-command -p '*.rst' -c '$(MAKE) -C docs html' -R -D . +CONTENT_URL := http://research.cs.aalto.fi/pml/software/elfi/docs/0.5/ + +notebook-docs: ## Conver notebooks to rst docs. Assumes you have them in `notebooks` directory. + jupyter nbconvert --to rst notebooks/quickstart.ipynb --output-dir docs + sed -i 's|\(quickstart_files/quickstart.*\.\)|'${CONTENT_URL}'\1|g' docs/quickstart.rst + + jupyter nbconvert --to rst notebooks/tutorial.ipynb --output-dir docs/usage + sed -i 's|\(tutorial_files/tutorial.*\.\)|'${CONTENT_URL}usage/'\1|g' docs/usage/tutorial.rst + + jupyter nbconvert --to rst notebooks/parallelization.ipynb --output-dir docs/usage + sed -i 's|\(parallelization_files/parallelization.*\.\)|'${CONTENT_URL}usage/'\1|g' docs/usage/parallelization.rst + + jupyter nbconvert --to rst notebooks/non_python_operations.ipynb --output-dir docs/usage --output=external + sed -i 's|\(external_files/external.*\.\)|'${CONTENT_URL}usage/'\1|g' docs/usage/external.rst # release: clean ## package and upload a release # python setup.py sdist upload diff --git a/README.md b/README.md index 53992525..1781b78c 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,6 @@ -**Version 0.5 released!** This introduces many new features and small but significant changes in syntax. See the CHANGELOG and [notebooks](https://github.com/elfi-dev/notebooks). +**Version 0.5 released!** This introduces many new features and small but significant +*changes in syntax. See the CHANGELOG and +*[notebooks](https://github.com/elfi-dev/notebooks). ELFI - Engine for Likelihood-Free Inference @@ -11,80 +13,12 @@ ELFI - Engine for Likelihood-Free Inference -ELFI is a statistical software package written in Python for performing inference with -generative models. The term "likelihood-free inference" refers to a family of inference -methods that replace the use of the likelihood function with a data generating simulator -function. This is useful when the likelihood function is not computable or otherwise -available but it is possible to make simulations of the process. +ELFI is a statistical software package for likelihood-free inference (LFI) such as +Approximate Bayesian Computation +([ABC](https://en.wikipedia.org/wiki/Approximate_Bayesian_computation)). -Other names or related approaches to likelihood-free inference include Approximative -Bayesian Computation ([ABC](https://en.wikipedia.org/wiki/Approximate_Bayesian_computation)), -simulator-based inference, approximative Bayesian inference, indirect inference, etc. - -Currently implemented ABC methods: -- ABC Rejection sampler -- Sequential Monte Carlo ABC sampler -- [Bayesian Optimization for Likelihood-Free Inference (BOLFI)](http://jmlr.csail.mit.edu/papers/v17/15-017.html) - -Other notable included algorithms and methods: -- Bayesian Optimization -- [No-U-Turn-Sampler](http://jmlr.org/papers/volume15/hoffman14a/hoffman14a.pdf), a Hamiltonian Monte Carlo MCMC sampler - -ELFI includes an easy to use generative modeling syntax, where the generative model is -specified as a directed acyclic graph (DAG). The data generation process can then be -automatically parallelized from multiple cores up to a cluster environment. ELFI also -handles seeding the random number generators and storing of the generated data for you so -that you can easily repeat or fine tune your inference. - -See examples under [notebooks](https://github.com/elfi-dev/notebooks) to get started. Full -documentation can be found at http://elfi.readthedocs.io/. Limited user-support may be -asked from elfi-support.at.hiit.fi, but the -[Gitter chat](https://gitter.im/elfi-dev/elfi?utm_source=share-link&utm_medium=link&utm_campaign=share-link) +See the [documentation](http://elfi.readthedocs.io/) for more details. We also have +several [jupyter notebooks](https://github.com/elfi-dev/notebooks) available to +get you going quickly. Limited user-support may be asked from elfi-support.at.hiit.fi, but +the [Gitter chat](https://gitter.im/elfi-dev/elfi?utm_source=share-link&utm_medium=link&utm_campaign=share-link) is preferable. - - -Installation ------------- - -ELFI requires and is tested with Python 3.5. - -``` -pip install elfi -``` - -Note that in some environments you may need to first install `numpy` with -`pip install numpy`. This is due to our dependency to `GPy` that uses `numpy` in its installation. - -### Optional dependencies - -- `graphviz` for drawing graphical models (needs [Graphviz](http://www.graphviz.org)), highly recommended - - -### Python 3 - -On some platforms you may have to use `pip3 install elfi`, in order to use Python 3. -If you are new to Python, perhaps the simplest way to install a specific version of Python -is with [Anaconda](https://www.continuum.io/downloads). - -### Virtual environment using Anaconda - -It is very practical to create a virtual Python environment. This way you won't interfere -with your default Python environment and can easily use different versions of Python -in different projects. You can create a virtual environment for ELFI using anaconda with: - -``` -conda create -n elfi python=3.5 numpy -source activate elfi -pip install elfi -``` - -### Potential problems with installation - -ELFI depends on several other Python packages, which have their own dependencies. -Resolving these may sometimes go wrong: -- If you receive an error about missing `numpy`, please install it first. -- If you receive an error about `yaml.load`, install `pyyaml`. -- On OS X with Anaconda virtual environment say `conda install python.app` and then use -`pythonw` instead of `python`. -- Note that ELFI currently supports Python 3.5 only, although 3.x may work as well, -so try `pip3 install elfi`. diff --git a/docs/api.rst b/docs/api.rst new file mode 100644 index 00000000..491fdb80 --- /dev/null +++ b/docs/api.rst @@ -0,0 +1,122 @@ +API +=== +This file describes the classes and methods available in ELFI. + +Modelling API +------------- +Below is the API for creating generative models. + +.. autosummary:: + elfi.ElfiModel + +**General model nodes** + +.. autosummary:: + elfi.Constant + elfi.Operation + elfi.RandomVariable + +**LFI nodes** + +.. autosummary:: + elfi.Prior + elfi.Simulator + elfi.Summary + elfi.Discrepancy + elfi.Distance + + +Inference API +------------- +Below is a list of inference methods included in ELFI. + +.. autosummary:: + elfi.Rejection + elfi.SMC + elfi.BayesianOptimization + elfi.BOLFI + +**Result objects** + +.. currentmodule:: elfi.methods.results + +.. autosummary:: + Result + ResultSMC + ResultBOLFI + +Class documentations +-------------------- + +Modelling API classes +..................... + +.. autoclass:: elfi.ElfiModel + :members: + :inherited-members: + +.. autoclass:: elfi.Constant + :members: + :inherited-members: + +.. autoclass:: elfi.Operation + :members: + :inherited-members: + +.. autoclass:: elfi.RandomVariable + :members: + :inherited-members: + +.. autoclass:: elfi.Prior + :members: + :inherited-members: + +.. autoclass:: elfi.Simulator + :members: + :inherited-members: + +.. autoclass:: elfi.Summary + :members: + :inherited-members: + +.. autoclass:: elfi.Discrepancy + :members: + :inherited-members: + +.. autoclass:: elfi.Distance + :members: + :inherited-members: + +.. This would show undocumented members :undoc-members: + + +Inference API classes +..................... + +.. autoclass:: elfi.Rejection + :members: + :inherited-members: + +.. autoclass:: elfi.SMC + :members: + :inherited-members: + +.. autoclass:: elfi.BayesianOptimization + :members: + :inherited-members: + +.. autoclass:: elfi.BOLFI + :members: + :inherited-members: + +.. autoclass:: Result + :members: + :inherited-members: + +.. autoclass:: ResultSMC + :members: + :inherited-members: + +.. autoclass:: ResultBOLFI + :members: + :inherited-members: \ No newline at end of file diff --git a/docs/conf.py b/docs/conf.py index 61004a51..9a5a5814 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -41,6 +41,7 @@ def __getattr__(cls, name): # https://github.com/rtfd/readthedocs.org/issues/1139 +""" def run_apidoc(_): from sphinx.apidoc import main import os @@ -54,6 +55,7 @@ def run_apidoc(_): def setup(app): app.connect('builder-inited', run_apidoc) +""" # If extensions (or modules to document with autodoc) are in another # directory, add these directories to sys.path here. If the directory is @@ -86,14 +88,15 @@ def setup(app): 'sphinx.ext.coverage', 'sphinx.ext.mathjax', 'sphinx.ext.viewcode', + 'sphinx.ext.autosummary', 'sphinx.ext.napoleon', - 'sphinx.ext.autosummary' - # Inheritance diagrams # 'sphinx.ext.graphviz', # 'sphinx.ext.inheritance_diagram', ] +autoclass_content = 'both' + # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] @@ -112,7 +115,7 @@ def setup(app): # General information about the project. project = 'ELFI' -copyright = '2016-2017, ELFI Developers and their Assignees' +copyright = '2017, ELFI Developers and their Assignees' author = 'ELFI authors' # The version info for the project you're documenting, acts as replacement for @@ -188,7 +191,10 @@ def setup(app): # further. For a list of options available for each theme, see the # documentation. # -# html_theme_options = {} +html_theme_options = { + 'collapse_navigation': True, + 'navigation_depth': 2, +} # Add any paths that contain custom themes here, relative to this directory. # html_theme_path = [] @@ -297,105 +303,3 @@ def setup(app): # Output file base name for HTML help builder. htmlhelp_basename = 'ELFIdoc' - -# -- Options for LaTeX output --------------------------------------------- - -latex_elements = { - # The paper size ('letterpaper' or 'a4paper'). - # - # 'papersize': 'letterpaper', - - # The font size ('10pt', '11pt' or '12pt'). - # - # 'pointsize': '10pt', - - # Additional stuff for the LaTeX preamble. - # - # 'preamble': '', - - # Latex figure (float) alignment - # - # 'figure_align': 'htbp', -} - -# Grouping the document tree into LaTeX files. List of tuples -# (source start file, target name, title, -# author, documentclass [howto, manual, or own class]). -latex_documents = [ - (master_doc, 'ELFI.tex', 'ELFI Documentation', - 'ELFI authors', 'manual'), -] - -# The name of an image file (relative to this directory) to place at the top of -# the title page. -# -# latex_logo = None - -# For "manual" documents, if this is true, then toplevel headings are parts, -# not chapters. -# -# latex_use_parts = False - -# If true, show page references after internal links. -# -# latex_show_pagerefs = False - -# If true, show URL addresses after external links. -# -# latex_show_urls = False - -# Documents to append as an appendix to all manuals. -# -# latex_appendices = [] - -# It false, will not define \strong, \code, itleref, \crossref ... but only -# \sphinxstrong, ..., \sphinxtitleref, ... To help avoid clash with user added -# packages. -# -# latex_keep_old_macro_names = True - -# If false, no module index is generated. -# -# latex_domain_indices = True - - -# -- Options for manual page output --------------------------------------- - -# One entry per manual page. List of tuples -# (source start file, name, description, authors, manual section). -man_pages = [ - (master_doc, 'elfi', 'ELFI Documentation', - [author], 1) -] - -# If true, show URL addresses after external links. -# -# man_show_urls = False - - -# -- Options for Texinfo output ------------------------------------------- - -# Grouping the document tree into Texinfo files. List of tuples -# (source start file, target name, title, author, -# dir menu entry, description, category) -texinfo_documents = [ - (master_doc, 'ELFI', 'ELFI Documentation', - author, 'ELFI', 'Modular ABC inference framework for Python', - 'Miscellaneous'), -] - -# Documents to append as an appendix to all manuals. -# -# texinfo_appendices = [] - -# If false, no module index is generated. -# -# texinfo_domain_indices = True - -# How to display URL addresses: 'footnote', 'no', or 'inline'. -# -# texinfo_show_urls = 'footnote' - -# If true, do not generate a @detailmenu in the "Top" node's menu. -# -# texinfo_no_detailmenu = False diff --git a/docs/contributing.rst b/docs/contributing.rst deleted file mode 100644 index e582053e..00000000 --- a/docs/contributing.rst +++ /dev/null @@ -1 +0,0 @@ -.. include:: ../CONTRIBUTING.rst diff --git a/docs/description.rst b/docs/description.rst new file mode 100644 index 00000000..4fdec284 --- /dev/null +++ b/docs/description.rst @@ -0,0 +1,7 @@ +ELFI is a statistical software package for likelihood-free inference (LFI) such as +Approximate Bayesian Computation (ABC_). The term LFI refers to a family of inference +methods that replace the use of the likelihood function with a data generating simulator +function. ELFI features an easy to use generative modeling syntax and supports +parallelized inference out of the box. + +.. _ABC: https://en.wikipedia.org/wiki/Approximate_Bayesian_computation diff --git a/docs/developer/architecture.rst b/docs/developer/architecture.rst new file mode 100644 index 00000000..b6a3499a --- /dev/null +++ b/docs/developer/architecture.rst @@ -0,0 +1,2 @@ +ELFI architecture +================= \ No newline at end of file diff --git a/docs/developer/contributing.rst b/docs/developer/contributing.rst new file mode 100644 index 00000000..ac7b6bcf --- /dev/null +++ b/docs/developer/contributing.rst @@ -0,0 +1 @@ +.. include:: ../../CONTRIBUTING.rst diff --git a/docs/developer/extensions.rst b/docs/developer/extensions.rst new file mode 100644 index 00000000..75df4a4c --- /dev/null +++ b/docs/developer/extensions.rst @@ -0,0 +1,2 @@ +Extending ELFI +============== \ No newline at end of file diff --git a/docs/elfi.bo.rst b/docs/elfi.bo.rst deleted file mode 100644 index 13e5444c..00000000 --- a/docs/elfi.bo.rst +++ /dev/null @@ -1,38 +0,0 @@ -elfi\.bo package -================ - -Submodules ----------- - -elfi\.bo\.acquisition module ----------------------------- - -.. automodule:: elfi.bo.acquisition - :members: - :undoc-members: - :show-inheritance: - -elfi\.bo\.gpy\_regression module --------------------------------- - -.. automodule:: elfi.bo.gpy_regression - :members: - :undoc-members: - :show-inheritance: - -elfi\.bo\.utils module ----------------------- - -.. automodule:: elfi.bo.utils - :members: - :undoc-members: - :show-inheritance: - - -Module contents ---------------- - -.. automodule:: elfi.bo - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/elfi.clients.rst b/docs/elfi.clients.rst deleted file mode 100644 index 0fe693a9..00000000 --- a/docs/elfi.clients.rst +++ /dev/null @@ -1,30 +0,0 @@ -elfi\.clients package -===================== - -Submodules ----------- - -elfi\.clients\.ipyparallel module ---------------------------------- - -.. automodule:: elfi.clients.ipyparallel - :members: - :undoc-members: - :show-inheritance: - -elfi\.clients\.native module ----------------------------- - -.. automodule:: elfi.clients.native - :members: - :undoc-members: - :show-inheritance: - - -Module contents ---------------- - -.. automodule:: elfi.clients - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/elfi.methods.rst b/docs/elfi.methods.rst deleted file mode 100644 index 67ed5821..00000000 --- a/docs/elfi.methods.rst +++ /dev/null @@ -1,30 +0,0 @@ -elfi\.methods package -===================== - -Submodules ----------- - -elfi\.methods\.methods module ------------------------------ - -.. automodule:: elfi.methods.methods - :members: - :undoc-members: - :show-inheritance: - -elfi\.methods\.posteriors module --------------------------------- - -.. automodule:: elfi.methods.posteriors - :members: - :undoc-members: - :show-inheritance: - - -Module contents ---------------- - -.. automodule:: elfi.methods - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/elfi.model.rst b/docs/elfi.model.rst deleted file mode 100644 index dee35afc..00000000 --- a/docs/elfi.model.rst +++ /dev/null @@ -1,30 +0,0 @@ -elfi\.model package -=================== - -Submodules ----------- - -elfi\.model\.elfi\_model module -------------------------------- - -.. automodule:: elfi.model.elfi_model - :members: - :undoc-members: - :show-inheritance: - -elfi\.model\.extensions module ------------------------------- - -.. automodule:: elfi.model.extensions - :members: - :undoc-members: - :show-inheritance: - - -Module contents ---------------- - -.. automodule:: elfi.model - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/elfi.results.rst b/docs/elfi.results.rst deleted file mode 100644 index 563082b6..00000000 --- a/docs/elfi.results.rst +++ /dev/null @@ -1,22 +0,0 @@ -elfi\.results package -===================== - -Submodules ----------- - -elfi\.results\.result module ----------------------------- - -.. automodule:: elfi.results.result - :members: - :undoc-members: - :show-inheritance: - - -Module contents ---------------- - -.. automodule:: elfi.results - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/elfi.rst b/docs/elfi.rst deleted file mode 100644 index 35e44a5f..00000000 --- a/docs/elfi.rst +++ /dev/null @@ -1,90 +0,0 @@ -elfi package -============ - -Subpackages ------------ - -.. toctree:: - - elfi.bo - elfi.clients - elfi.methods - elfi.model - elfi.results - elfi.visualization - -Submodules ----------- - -elfi\.client module -------------------- - -.. automodule:: elfi.client - :members: - :undoc-members: - :show-inheritance: - -elfi\.compiler module ---------------------- - -.. automodule:: elfi.compiler - :members: - :undoc-members: - :show-inheritance: - -elfi\.executor module ---------------------- - -.. automodule:: elfi.executor - :members: - :undoc-members: - :show-inheritance: - -elfi\.graphical\_model module ------------------------------ - -.. automodule:: elfi.graphical_model - :members: - :undoc-members: - :show-inheritance: - -elfi\.loader module -------------------- - -.. automodule:: elfi.loader - :members: - :undoc-members: - :show-inheritance: - -elfi\.mcmc module ------------------ - -.. automodule:: elfi.mcmc - :members: - :undoc-members: - :show-inheritance: - -elfi\.store module ------------------- - -.. automodule:: elfi.store - :members: - :undoc-members: - :show-inheritance: - -elfi\.utils module ------------------- - -.. automodule:: elfi.utils - :members: - :undoc-members: - :show-inheritance: - - -Module contents ---------------- - -.. automodule:: elfi - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/elfi.visualization.rst b/docs/elfi.visualization.rst deleted file mode 100644 index 56ccaa73..00000000 --- a/docs/elfi.visualization.rst +++ /dev/null @@ -1,30 +0,0 @@ -elfi\.visualization package -=========================== - -Submodules ----------- - -elfi\.visualization\.interactive module ---------------------------------------- - -.. automodule:: elfi.visualization.interactive - :members: - :undoc-members: - :show-inheritance: - -elfi\.visualization\.visualization module ------------------------------------------ - -.. automodule:: elfi.visualization.visualization - :members: - :undoc-members: - :show-inheritance: - - -Module contents ---------------- - -.. automodule:: elfi.visualization - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/faq.rst b/docs/faq.rst new file mode 100644 index 00000000..2d20dfe2 --- /dev/null +++ b/docs/faq.rst @@ -0,0 +1,4 @@ +Frequently Asked Questions +========================== + +TODO diff --git a/docs/index.rst b/docs/index.rst index 6caa67ae..a18baaf7 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,25 +1,71 @@ -.. ELFI documentation master file, created by - sphinx-quickstart on Thu Oct 27 09:49:40 2016. - You can adapt this file completely to your liking, but it should at least - contain the root `toctree` directive. +.. ELFI documentation master file. It should at least contain the root `toctree` directive. -Welcome to ELFI's documentation! -================================ -Contents: +ELFI - Engine for Likelihood-Free Inference +=========================================== + +.. include:: description.rst + +.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.5/images/ma2.png + :alt: MA2 model in ELFI + :align: right + +See :doc:`the quickstart ` to get started. + +ELFI is licensed under BSD3_. The source is in GitHub_. + +.. _BSD3: https://opensource.org/licenses/BSD-3-Clause +.. _GitHub: https://github.com/elfi-dev/elfi + + +Currently implemented LFI methods: +---------------------------------- + +- ABC rejection sampler +- Sequential Monte Carlo ABC sampler +- Bayesian Optimization for Likelihood-Free Inference (BOLFI_) framework + +.. _BOLFI: http://jmlr.org/papers/v17/15-017.html + +ELFI also has the following non LFI methods: + +- Bayesian Optimization +- No-U-Turn-Sampler_, a Hamiltonian Monte Carlo MCMC sampler + +.. _No-U-Turn-Sampler: http://jmlr.org/papers/volume15/hoffman14a/hoffman14a.pdf + + +.. toctree:: + :maxdepth: 1 + :caption: Getting started + + installation + quickstart + api .. toctree:: - :maxdepth: 2 + :maxdepth: 1 + :caption: Usage + + usage/tutorial + usage/parallelization + usage/external + +.. toctree:: + :maxdepth: 1 + :caption: Developer documentation + + developer/contributing + +.. faq + +.. usage/implementing-methods + +.. developer/architecture +.. developer/extensions - readme - installation - usage - contributing -Indices and tables -================== -* :ref:`genindex` -* :ref:`modindex` -* :ref:`search` +Other names or related approaches to LFI include simulator-based inference, approximate +Bayesian inference, indirect inference, etc. \ No newline at end of file diff --git a/docs/installation.rst b/docs/installation.rst index 19171a57..fc650b3d 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -1,59 +1,60 @@ .. highlight:: shell -============ Installation ============ -To install ELFI, run this command in your terminal: +ELFI requires Python 3.5 or greater (see below how to install). To install ELFI, simply +type in your terminal: .. code-block:: console pip install elfi -If you don't have `pip`_ installed, this `Python installation guide`_ can guide -you through the process. +In some OS you may have to first install ``numpy`` with ``pip install numpy``. If you don't +have `pip`_ installed, this `Python installation guide`_ can guide you through the +process. .. _pip: https://pip.pypa.io .. _Python installation guide: http://docs.python-guide.org/en/latest/starting/installation/ -ELFI is currently tested only with Python 3.5. If you are new to Python, perhaps the simplest way to install it is Anaconda_ - -.. _Anaconda: https://www.continuum.io/downloads - -Optional dependencies +Installing Python 3.5 --------------------- -Optionally you may wish to install also the following packages: +If you are new to Python, perhaps the simplest way to install it is with Anaconda_ that +manages different Python versions. After installing Anaconda, you can create a Python 3.5. +environment with ELFI: -* `graphviz` for drawing graphical models (Graphviz_ must be installed separately) +.. code-block:: console -.. _Graphviz: http://www.graphviz.org + conda create -n elfi python=3.5 numpy + source activate elfi + pip install elfi -Virtual environment using Anaconda ----------------------------------- +.. _Anaconda: https://www.continuum.io/downloads -If you want to create a virtual environment before installing, you can do so with Anaconda: +Optional dependencies +--------------------- -.. code-block:: console +We recommend to install: - conda create -n elfi python=3.5 scipy - source activate elfi - pip install elfi +* ``graphviz`` for drawing graphical models (``pip install graphviz`` requires Graphviz_ binaries which are already available in many unix-like OS). +.. _Graphviz: http://www.graphviz.org Potential problems with installation ------------------------------------ -ELFI depends on several other Python packages, which have their own dependencies. Resolving these may sometimes go wrong: +ELFI depends on several other Python packages, which have their own dependencies. +Resolving these may sometimes go wrong: -* If you receive an error about missing `numpy`, please install it first. -* If you receive an error about `yaml.load`, install `pyyaml`. +* If you receive an error about missing ``numpy``, please install it first. +* If you receive an error about `yaml.load`, install ``pyyaml``. * On OS X with Anaconda virtual environment say `conda install python.app` and then use `pythonw` instead of `python`. -* Note that ELFI currently supports Python 3.5 only, although 3.x may work as well. +* Note that ELFI requires Python 3.5 or greater -From sources ------------- +Developer installation from sources +----------------------------------- The sources for ELFI can be downloaded from the `Github repo`_. @@ -63,13 +64,14 @@ You can either clone the public repository: git clone https://github.com/elfi-dev/elfi.git -Or download the `tarball`_: +Or download the development `tarball`_: .. code-block:: console - curl -OL https://github.com/elfi-dev/elfi/tarball/master + curl -OL https://github.com/elfi-dev/elfi/tarball/dev -Note that for development it is recommended to base your work on the `dev` branch instead of `master`. +Note that for development it is recommended to base your work on the `dev` branch instead +of `master`. Once you have a copy of the source, you can install it with: @@ -80,5 +82,5 @@ Once you have a copy of the source, you can install it with: This will install ELFI along with its default requirements. .. _Github repo: https://github.com/elfi-dev/elfi -.. _tarball: https://github.com/elfi-dev/elfi/tarball/master +.. _tarball: https://github.com/elfi-dev/elfi/tarball/dev diff --git a/docs/make.bat b/docs/make.bat deleted file mode 100644 index 93ebb910..00000000 --- a/docs/make.bat +++ /dev/null @@ -1,281 +0,0 @@ -@ECHO OFF - -REM Command file for Sphinx documentation - -if "%SPHINXBUILD%" == "" ( - set SPHINXBUILD=sphinx-build -) -set BUILDDIR=_build -set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . -set I18NSPHINXOPTS=%SPHINXOPTS% . -if NOT "%PAPER%" == "" ( - set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% - set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% -) - -if "%1" == "" goto help - -if "%1" == "help" ( - :help - echo.Please use `make ^` where ^ is one of - echo. html to make standalone HTML files - echo. dirhtml to make HTML files named index.html in directories - echo. singlehtml to make a single large HTML file - echo. pickle to make pickle files - echo. json to make JSON files - echo. htmlhelp to make HTML files and a HTML help project - echo. qthelp to make HTML files and a qthelp project - echo. devhelp to make HTML files and a Devhelp project - echo. epub to make an epub - echo. epub3 to make an epub3 - echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter - echo. text to make text files - echo. man to make manual pages - echo. texinfo to make Texinfo files - echo. gettext to make PO message catalogs - echo. changes to make an overview over all changed/added/deprecated items - echo. xml to make Docutils-native XML files - echo. pseudoxml to make pseudoxml-XML files for display purposes - echo. linkcheck to check all external links for integrity - echo. doctest to run all doctests embedded in the documentation if enabled - echo. coverage to run coverage check of the documentation if enabled - echo. dummy to check syntax errors of document sources - goto end -) - -if "%1" == "clean" ( - for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i - del /q /s %BUILDDIR%\* - goto end -) - - -REM Check if sphinx-build is available and fallback to Python version if any -%SPHINXBUILD% 1>NUL 2>NUL -if errorlevel 9009 goto sphinx_python -goto sphinx_ok - -:sphinx_python - -set SPHINXBUILD=python -m sphinx.__init__ -%SPHINXBUILD% 2> nul -if errorlevel 9009 ( - echo. - echo.The 'sphinx-build' command was not found. Make sure you have Sphinx - echo.installed, then set the SPHINXBUILD environment variable to point - echo.to the full path of the 'sphinx-build' executable. Alternatively you - echo.may add the Sphinx directory to PATH. - echo. - echo.If you don't have Sphinx installed, grab it from - echo.http://sphinx-doc.org/ - exit /b 1 -) - -:sphinx_ok - - -if "%1" == "html" ( - %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The HTML pages are in %BUILDDIR%/html. - goto end -) - -if "%1" == "dirhtml" ( - %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. - goto end -) - -if "%1" == "singlehtml" ( - %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. - goto end -) - -if "%1" == "pickle" ( - %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; now you can process the pickle files. - goto end -) - -if "%1" == "json" ( - %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; now you can process the JSON files. - goto end -) - -if "%1" == "htmlhelp" ( - %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; now you can run HTML Help Workshop with the ^ -.hhp project file in %BUILDDIR%/htmlhelp. - goto end -) - -if "%1" == "qthelp" ( - %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; now you can run "qcollectiongenerator" with the ^ -.qhcp project file in %BUILDDIR%/qthelp, like this: - echo.^> qcollectiongenerator %BUILDDIR%\qthelp\ELFI.qhcp - echo.To view the help file: - echo.^> assistant -collectionFile %BUILDDIR%\qthelp\ELFI.ghc - goto end -) - -if "%1" == "devhelp" ( - %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. - goto end -) - -if "%1" == "epub" ( - %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The epub file is in %BUILDDIR%/epub. - goto end -) - -if "%1" == "epub3" ( - %SPHINXBUILD% -b epub3 %ALLSPHINXOPTS% %BUILDDIR%/epub3 - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The epub3 file is in %BUILDDIR%/epub3. - goto end -) - -if "%1" == "latex" ( - %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. - goto end -) - -if "%1" == "latexpdf" ( - %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex - cd %BUILDDIR%/latex - make all-pdf - cd %~dp0 - echo. - echo.Build finished; the PDF files are in %BUILDDIR%/latex. - goto end -) - -if "%1" == "latexpdfja" ( - %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex - cd %BUILDDIR%/latex - make all-pdf-ja - cd %~dp0 - echo. - echo.Build finished; the PDF files are in %BUILDDIR%/latex. - goto end -) - -if "%1" == "text" ( - %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The text files are in %BUILDDIR%/text. - goto end -) - -if "%1" == "man" ( - %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The manual pages are in %BUILDDIR%/man. - goto end -) - -if "%1" == "texinfo" ( - %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. - goto end -) - -if "%1" == "gettext" ( - %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The message catalogs are in %BUILDDIR%/locale. - goto end -) - -if "%1" == "changes" ( - %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes - if errorlevel 1 exit /b 1 - echo. - echo.The overview file is in %BUILDDIR%/changes. - goto end -) - -if "%1" == "linkcheck" ( - %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck - if errorlevel 1 exit /b 1 - echo. - echo.Link check complete; look for any errors in the above output ^ -or in %BUILDDIR%/linkcheck/output.txt. - goto end -) - -if "%1" == "doctest" ( - %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest - if errorlevel 1 exit /b 1 - echo. - echo.Testing of doctests in the sources finished, look at the ^ -results in %BUILDDIR%/doctest/output.txt. - goto end -) - -if "%1" == "coverage" ( - %SPHINXBUILD% -b coverage %ALLSPHINXOPTS% %BUILDDIR%/coverage - if errorlevel 1 exit /b 1 - echo. - echo.Testing of coverage in the sources finished, look at the ^ -results in %BUILDDIR%/coverage/python.txt. - goto end -) - -if "%1" == "xml" ( - %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The XML files are in %BUILDDIR%/xml. - goto end -) - -if "%1" == "pseudoxml" ( - %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. - goto end -) - -if "%1" == "dummy" ( - %SPHINXBUILD% -b dummy %ALLSPHINXOPTS% %BUILDDIR%/dummy - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. Dummy builder generates no files. - goto end -) - -:end diff --git a/docs/modules.rst b/docs/modules.rst deleted file mode 100644 index 1d486d8c..00000000 --- a/docs/modules.rst +++ /dev/null @@ -1,7 +0,0 @@ -elfi -==== - -.. toctree:: - :maxdepth: 4 - - elfi diff --git a/docs/quickstart.rst b/docs/quickstart.rst new file mode 100644 index 00000000..ca8a2632 --- /dev/null +++ b/docs/quickstart.rst @@ -0,0 +1,132 @@ + +Quickstart +========== + +First ensure you have `installed `__ Python 3.5 (or +greater) and ELFI. After installation you can start using ELFI: + +.. code:: python + + import elfi + +ELFI includes an easy to use generative modeling syntax, where the +generative model is specified as a directed acyclic graph (DAG). Let’s +create two prior nodes: + +.. code:: python + + mu = elfi.Prior('uniform', -2, 4) + sigma = elfi.Prior('uniform', 1, 4) + +The above would create two prior nodes, a uniform distribution from -2 +to 2 for the mean ``mu`` and another uniform distribution from 1 to 5 +for the standard deviation ``sigma``. All distributions from +``scipy.stats`` are available. + +For likelihood-free models we typically need to define a simulator and +summary statistics for the data. As an example, lets define the +simulator as 30 draws from a Gaussian distribution with a given mean and +standard deviation. Let's use mean and variance as our summaries: + +.. code:: python + + import scipy.stats as ss + import numpy as np + + def simulator(mu, sigma, batch_size=1, random_state=None): + mu, sigma = np.atleast_1d(mu, sigma) + return ss.norm.rvs(mu[:, None], sigma[:, None], size=(batch_size, 30), random_state=random_state) + + def mean(y): + return np.mean(y, axis=1) + + def var(y): + return np.var(y, axis=1) + +Let’s now assume we have some observed data ``y0`` (here we just create +some with the simulator): + +.. code:: python + + # Set the generating parameters that we will try to infer + mean0 = 1 + std0 = 3 + + # Generate some data (using a fixed seed here) + np.random.seed(20170525) + y0 = simulator(mean0, std0) + print(y0) + + +.. parsed-literal:: + + [[ 3.7990926 1.49411834 0.90999905 2.46088006 -0.10696721 0.80490023 + 0.7413415 -5.07258261 0.89397268 3.55462229 0.45888389 -3.31930036 + -0.55378741 3.00865492 1.59394854 -3.37065996 5.03883749 -2.73279084 + 6.10128027 5.09388631 1.90079255 -1.7161259 3.86821266 0.4963219 + 1.64594033 -2.51620566 -0.83601666 2.68225112 2.75598375 -6.02538356]] + + +Now we have all the components needed. Let’s complete our model by +adding the simulator, the observed data, summaries and a distance to our +model: + +.. code:: python + + # Add the simulator node and observed data to the model + sim = elfi.Simulator(simulator, mu, sigma, observed=y0) + + # Add summary statistics to the model + S1 = elfi.Summary(mean, sim) + S2 = elfi.Summary(var, sim) + + # Specify distance as euclidean between summary vectors (S1, S2) from simulated and + # observed data + d = elfi.Distance('euclidean', S1, S2) + + # Plot the complete model (requires graphviz) + elfi.draw(d) + + + + +.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.5/quickstart_files/quickstart_9_0.svg + + + +We can try to infer the true generating parameters ``mean0`` and +``std0`` above with any of ELFI’s inference methods. Let’s use ABC +Rejection sampling and sample 1000 samples from the approximate +posterior using threshold value 0.5: + +.. code:: python + + rej = elfi.Rejection(d, batch_size=10000, seed=30052017) + res = rej.sample(1000, threshold=.5) + print(res) + + +.. parsed-literal:: + + Method: Rejection + Number of posterior samples: 1000 + Number of simulations: 120000 + Threshold: 0.498 + Posterior means: mu: 0.726, sigma: 3.09 + + + +Let's plot also the marginal distributions for the parameters: + +.. code:: python + + import matplotlib.pyplot as plt + res.plot_marginals() + plt.show() + + + +.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.5/quickstart_files/quickstart_13_0.png + + +For a more details, please see the `tutorial `__. diff --git a/docs/readme.rst b/docs/readme.rst deleted file mode 100644 index ccdb8639..00000000 --- a/docs/readme.rst +++ /dev/null @@ -1,39 +0,0 @@ -ELFI - Engine for Likelihood-Free Inference -=========================================== - -ELFI is a statistical software package written in Python for Approximative Bayesian Computation (ABC_), also known e.g. as likelihood-free inference, simulator-based inference, approximative Bayesian inference etc. This is useful, when the likelihood function is unknown or difficult to evaluate, but a generative simulator model exists. - -.. _ABC: https://en.wikipedia.org/wiki/Approximate_Bayesian_computation - -The probabilistic inference model is defined as a directed acyclic graph, which allows for an intuitive means to describe inherent dependencies in the model. The inference pipeline is automatically parallelized from multiple cores up to a cluster environment. ELFI also handles seeding the random number generators and storing of the generated data for you so that you can easily repeat or fine tune your inference. Additionally, the package includes functionality for visualization. - -Currently implemented ABC methods: - -- rejection sampler -- Sequential Monte Carlo ABC sampler -- Bayesian Optimization for Likelihood-Free Inference (BOLFI_) framework - -.. _BOLFI: http://jmlr.csail.mit.edu/papers/v17/15-017.html - -Other notable included algorithms and methods: - -- Bayesian Optimization -- No-U-Turn-Sampler_, a Hamiltonian Monte Carlo MCMC sampler - -.. _No-U-Turn-Sampler: http://jmlr.org/papers/volume15/hoffman14a/hoffman14a.pdf - -GitHub page: https://github.com/elfi-dev/elfi - -See examples under the notebooks_ directory to get started. Limited user-support may be asked from elfi-support.at.hiit.fi, but the `Gitter chat`_ is preferable. - -.. _notebooks: https://github.com/elfi-dev/notebooks -.. _Gitter chat: https://gitter.im/elfi-dev/elfi?utm_source=share-link&utm_medium=link&utm_campaign=share-link - -Licenses: - -- Code: BSD3_ -- Documentation: `CC-BY 4.0`_ - -.. _BSD3: https://opensource.org/licenses/BSD-3-Clause -.. _CC-BY 4.0: https://creativecommons.org/licenses/by/4.0 - diff --git a/docs/source/elfi.bo.rst b/docs/source/elfi.bo.rst deleted file mode 100644 index 13e5444c..00000000 --- a/docs/source/elfi.bo.rst +++ /dev/null @@ -1,38 +0,0 @@ -elfi\.bo package -================ - -Submodules ----------- - -elfi\.bo\.acquisition module ----------------------------- - -.. automodule:: elfi.bo.acquisition - :members: - :undoc-members: - :show-inheritance: - -elfi\.bo\.gpy\_regression module --------------------------------- - -.. automodule:: elfi.bo.gpy_regression - :members: - :undoc-members: - :show-inheritance: - -elfi\.bo\.utils module ----------------------- - -.. automodule:: elfi.bo.utils - :members: - :undoc-members: - :show-inheritance: - - -Module contents ---------------- - -.. automodule:: elfi.bo - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/elfi.clients.rst b/docs/source/elfi.clients.rst deleted file mode 100644 index 0fe693a9..00000000 --- a/docs/source/elfi.clients.rst +++ /dev/null @@ -1,30 +0,0 @@ -elfi\.clients package -===================== - -Submodules ----------- - -elfi\.clients\.ipyparallel module ---------------------------------- - -.. automodule:: elfi.clients.ipyparallel - :members: - :undoc-members: - :show-inheritance: - -elfi\.clients\.native module ----------------------------- - -.. automodule:: elfi.clients.native - :members: - :undoc-members: - :show-inheritance: - - -Module contents ---------------- - -.. automodule:: elfi.clients - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/elfi.methods.rst b/docs/source/elfi.methods.rst deleted file mode 100644 index b212b97b..00000000 --- a/docs/source/elfi.methods.rst +++ /dev/null @@ -1,38 +0,0 @@ -elfi\.methods package -===================== - -Submodules ----------- - -elfi\.methods\.methods module ------------------------------ - -.. automodule:: elfi.methods.methods - :members: - :undoc-members: - :show-inheritance: - -elfi\.methods\.posteriors module --------------------------------- - -.. automodule:: elfi.methods.posteriors - :members: - :undoc-members: - :show-inheritance: - -elfi\.methods\.utils module ---------------------------- - -.. automodule:: elfi.methods.utils - :members: - :undoc-members: - :show-inheritance: - - -Module contents ---------------- - -.. automodule:: elfi.methods - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/elfi.model.rst b/docs/source/elfi.model.rst deleted file mode 100644 index cd59e645..00000000 --- a/docs/source/elfi.model.rst +++ /dev/null @@ -1,46 +0,0 @@ -elfi\.model package -=================== - -Submodules ----------- - -elfi\.model\.elfi\_model module -------------------------------- - -.. automodule:: elfi.model.elfi_model - :members: - :undoc-members: - :show-inheritance: - -elfi\.model\.extensions module ------------------------------- - -.. automodule:: elfi.model.extensions - :members: - :undoc-members: - :show-inheritance: - -elfi\.model\.tools module -------------------------- - -.. automodule:: elfi.model.tools - :members: - :undoc-members: - :show-inheritance: - -elfi\.model\.utils module -------------------------- - -.. automodule:: elfi.model.utils - :members: - :undoc-members: - :show-inheritance: - - -Module contents ---------------- - -.. automodule:: elfi.model - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/elfi.results.rst b/docs/source/elfi.results.rst deleted file mode 100644 index 563082b6..00000000 --- a/docs/source/elfi.results.rst +++ /dev/null @@ -1,22 +0,0 @@ -elfi\.results package -===================== - -Submodules ----------- - -elfi\.results\.result module ----------------------------- - -.. automodule:: elfi.results.result - :members: - :undoc-members: - :show-inheritance: - - -Module contents ---------------- - -.. automodule:: elfi.results - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/elfi.rst b/docs/source/elfi.rst deleted file mode 100644 index 35e44a5f..00000000 --- a/docs/source/elfi.rst +++ /dev/null @@ -1,90 +0,0 @@ -elfi package -============ - -Subpackages ------------ - -.. toctree:: - - elfi.bo - elfi.clients - elfi.methods - elfi.model - elfi.results - elfi.visualization - -Submodules ----------- - -elfi\.client module -------------------- - -.. automodule:: elfi.client - :members: - :undoc-members: - :show-inheritance: - -elfi\.compiler module ---------------------- - -.. automodule:: elfi.compiler - :members: - :undoc-members: - :show-inheritance: - -elfi\.executor module ---------------------- - -.. automodule:: elfi.executor - :members: - :undoc-members: - :show-inheritance: - -elfi\.graphical\_model module ------------------------------ - -.. automodule:: elfi.graphical_model - :members: - :undoc-members: - :show-inheritance: - -elfi\.loader module -------------------- - -.. automodule:: elfi.loader - :members: - :undoc-members: - :show-inheritance: - -elfi\.mcmc module ------------------ - -.. automodule:: elfi.mcmc - :members: - :undoc-members: - :show-inheritance: - -elfi\.store module ------------------- - -.. automodule:: elfi.store - :members: - :undoc-members: - :show-inheritance: - -elfi\.utils module ------------------- - -.. automodule:: elfi.utils - :members: - :undoc-members: - :show-inheritance: - - -Module contents ---------------- - -.. automodule:: elfi - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/elfi.visualization.rst b/docs/source/elfi.visualization.rst deleted file mode 100644 index 56ccaa73..00000000 --- a/docs/source/elfi.visualization.rst +++ /dev/null @@ -1,30 +0,0 @@ -elfi\.visualization package -=========================== - -Submodules ----------- - -elfi\.visualization\.interactive module ---------------------------------------- - -.. automodule:: elfi.visualization.interactive - :members: - :undoc-members: - :show-inheritance: - -elfi\.visualization\.visualization module ------------------------------------------ - -.. automodule:: elfi.visualization.visualization - :members: - :undoc-members: - :show-inheritance: - - -Module contents ---------------- - -.. automodule:: elfi.visualization - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/modules.rst b/docs/source/modules.rst deleted file mode 100644 index 1d486d8c..00000000 --- a/docs/source/modules.rst +++ /dev/null @@ -1,7 +0,0 @@ -elfi -==== - -.. toctree:: - :maxdepth: 4 - - elfi diff --git a/docs/usage.rst b/docs/usage.rst deleted file mode 100644 index b981178c..00000000 --- a/docs/usage.rst +++ /dev/null @@ -1,12 +0,0 @@ -===== -Usage -===== - -To use ELFI in a project:: - - import elfi - -For tutorials, please see the Jupyter Notebooks under the `notebooks directory`_. Feel free to add your own in the zoo_. - -.. _notebooks directory: https://github.com/elfi-dev/notebooks -.. _zoo: https://github.com/elfi-dev/zoo diff --git a/docs/usage/external.rst b/docs/usage/external.rst new file mode 100644 index 00000000..152e3d17 --- /dev/null +++ b/docs/usage/external.rst @@ -0,0 +1,631 @@ + +Using non-Python operations +=========================== + +If your simulator or other operations are implemented in a programming +language other than Python, you can still use ELFI. This notebook +briefly demonstrates how to do this in three common scenarios: + +- External executable (written e.g. in C++ or a shell script) +- R function +- MATLAB function + +This tutorial is generated from a `Jupyter `__ +notebook that can be found +`here `__. Let's begin by +importing some libraries that we will be using: + +.. code:: python + + import os + import numpy as np + import matplotlib + import matplotlib.pyplot as plt + import scipy.io as sio + import scipy.stats as ss + + import elfi + import elfi.examples + + %matplotlib inline + +.. note:: To run some parts of this notebook you need to either compile the simulator, have R or MATLAB installed and install their respective wrapper libraries. + +External executables +-------------------- + +ELFI supports using external simulators and other operations that can be +called from the command-line. ELFI provides some tools to easily +incorporate such operations to ELFI models. This functionality is +introduced in this tutorial. + +We demonstrate here how to wrap executables as ELFI nodes. We will first +use ``elfi.tools.external_operation`` tool to wrap executables as a +Python callables (function). Let's first investigate how it works with a +simple shell ``echo`` command: + +.. code:: python + + # Make an external command. {0} {1} are positional arguments and {seed} a keyword argument `seed`. + command = 'echo {0} {1} {seed}' + echo_sim = elfi.tools.external_operation(command) + + # Test that `echo_sim` can now be called as a regular python function + echo_sim(3, 1, seed=123) + + + + +.. parsed-literal:: + + array([ 3., 1., 123.]) + + + +The placeholders for arguments in the command string are just Python's +```format strings`` `__. + +Currently ``echo_sim`` only accepts scalar arguments. In order to work +in ELFI, ``echo_sim`` needs to be vectorized so that we can pass to it a +vector of arguments. ELFI provides a handy tool for this as well: + +.. code:: python + + # Vectorize it with elfi tools + echo_sim_vec = elfi.tools.vectorize(echo_sim) + + # Make a simple model + m = elfi.ElfiModel(name='echo') + elfi.Prior('uniform', .005, 2, model=m, name='alpha') + elfi.Simulator(echo_sim_vec, m['alpha'], 0, name='echo') + + # Test to generate 3 simulations from it + m['echo'].generate(3) + + + + +.. parsed-literal:: + + array([[ 4.81966633e-01, 0.00000000e+00, 1.08163575e+09], + [ 1.46447661e+00, 0.00000000e+00, 2.81716645e+09], + [ 8.85613616e-01, 0.00000000e+00, 3.66083810e+09]]) + + + +So above, the first column draws from our uniform prior for +:math:`\alpha`, the second column has constant zeros, and the last one +lists the seeds provided to the command by ELFI. + +Complex external operations :math:`-` case BDM +---------------------------------------------- + +To provide a more realistic example of external operations, we will +consider the Birth-Death-Mutation (BDM) model used in `*Lintusaari at al +2016* `__ *[1]*. + +Birth-Death-Mutation process +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +We will consider here the Birth-Death-Mutation process simulator +introduced in *Tanaka et al 2006 [2]* for the spread of Tuberculosis. +The simulator outputs a count vector where each of its elements +represents a "mutation" of the disease and the count describes how many +are currently infected by that mutation. There are three rates and the +population size: + +- :math:`\alpha` - (birth rate) the rate at which any infectious host + transmits the disease. +- :math:`\delta` - (death rate) the rate at which any existing + infectious hosts either recovers or dies. +- :math:`\tau` - (mutation rate) the rate at which any infectious host + develops a new unseen mutation of the disease within themselves. +- :math:`N` - (population size) the size of the simulated infectious + population + +It is assumed that the susceptible population is infinite, the hosts +carry only one mutation of the disease and transmit that mutation +onward. A more accurate description of the model can be found from the +original paper or e.g. `*Lintusaari at al +2016* `__ *[1]*. + +.. For documentation + +.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.5/images/bdm.png + :width: 400 px + :alt: BDM model illustration from Lintusaari et al. 2016 + :align: center + +This simulator cannot be implemented effectively with vectorized +operations so we have implemented it with C++ that handles loops +efficiently. We will now reproduce Figure 6(a) in `*Lintusaari at al +2016* `__ *[2]* with ELFI. Let's +start by defining some constants: + +.. code:: python + + # Fixed model parameters + delta = 0 + tau = 0.198 + N = 20 + + # The zeros are to make the observed population vector have length N + y_obs = np.array([6, 3, 2, 2, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0], dtype='int16') + +Let's build the beginning of a new model for the birth rate +:math:`\alpha` as the only unknown + +.. code:: python + + m = elfi.ElfiModel(name='bdm') + elfi.Prior('uniform', .005, 2, model=m, name='alpha') + + + + +.. parsed-literal:: + + Prior(name='alpha', 'uniform') + + + +.. code:: python + + # Get the BDM source directory + sources_path = elfi.examples.bdm.get_sources_path() + + # Copy to resources folder and compile (unix-like systems) + !cp -r $sources_path resources + !make -C resources/cpp + + # Move the file in to the working directory + !mv ./resources/cpp/bdm . + + +.. parsed-literal:: + + make: Entering directory '/l/lintusj1/notebooks-elfi/resources/cpp' + g++ bdm.cpp --std=c++0x -O -Wall -o bdm + make: Leaving directory '/l/lintusj1/notebooks-elfi/resources/cpp' + + +.. note:: The source code for the BDM simulator comes with ELFI. You can get the directory with `elfi.examples.bdm.get_source_directory()`. Under unix-like systems it can be compiled with just typing `make` to console in the source directory. For windows systems, you need to have some C++ compiler available to compile it. + +.. code:: python + + # Test the executable (assuming we have the executable `bdm` in the working directory) + sim = elfi.tools.external_operation('./bdm {0} {1} {2} {3} --seed {seed} --mode 1') + sim(1, delta, tau, N, seed=123) + + + + +.. parsed-literal:: + + array([ 19., 1., 0., 0., 0., 0., 0., 0., 0., 0., 0., + 0., 0., 0., 0., 0., 0., 0., 0., 0.]) + + + +The BDM simulator is actually already internally vectorized if you +provide it an input file with parameters on the rows. This is more +efficient than looping in Python (``elfi.tools.vectorize``), because one +simulation takes very little time and we wish to generate tens of +thousands of simulations. We will also here redirect the output to a +file and then read the file into a numpy array. + +This is just one possibility among the many to implement this. The most +efficient would be to write a native Python module with C++ but it's +beyond the scope of this article. So let's work through files which is a +fairly common situation especially with existing software. + +.. code:: python + + # Assuming we have the executable `bdm` in the working directory + command = './bdm {filename} --seed {seed} --mode 1 > {output_filename}' + + + # Function to prepare the inputs for the simulator. We will create filenames and write an input file. + def prepare_inputs(*inputs, **kwinputs): + alpha, delta, tau, N = inputs + meta = kwinputs['meta'] + + # Organize the parameters to an array. The broadcasting works nicely with constant arguments here. + param_array = np.row_stack(np.broadcast(alpha, delta, tau, N)) + + # Prepare a unique filename for parallel settings + filename = '{model_name}_{batch_index}_{submission_index}.txt'.format(**meta) + np.savetxt(filename, param_array, fmt='%.4f %.4f %.4f %d') + + # Add the filenames to kwinputs + kwinputs['filename'] = filename + kwinputs['output_filename'] = filename[:-4] + '_out.txt' + + # Return new inputs that the command will receive + return inputs, kwinputs + + + # Function to process the result of the simulation + def process_result(completed_process, *inputs, **kwinputs): + output_filename = kwinputs['output_filename'] + + # Read the simulations from the file. + simulations = np.loadtxt(output_filename, dtype='int16') + + # Clean up the files after reading the data in + os.remove(kwinputs['filename']) + os.remove(output_filename) + + # This will be passed to ELFI as the result of the command + return simulations + + + # Create the python function (do not read stdout since we will work through files) + bdm = elfi.tools.external_operation(command, + prepare_inputs=prepare_inputs, + process_result=process_result, + stdout=False) + +Now let's replace the echo simulator with this. To create unique but +informative filenames, we ask ELFI to provide the operation some meta +information. That will be available under the ``meta`` keyword (see the +``prepare_inputs`` function above): + +.. code:: python + + # Create the simulator + bdm_node = elfi.Simulator(bdm, m['alpha'], delta, tau, N, observed=y_obs, name='sim') + + # Ask ELFI to provide the meta dict + bdm_node.uses_meta = True + + # Draw the model + elfi.draw(m) + + + + +.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.5/usage/external_files/external_20_0.svg + + + +.. code:: python + + # Test it + data = bdm_node.generate(3) + print(data) + + +.. parsed-literal:: + + [[ 3 1 2 1 4 2 1 1 1 1 1 1 1 0 0 0 0 0 0 0] + [ 1 1 1 1 1 1 1 2 1 1 1 1 1 1 2 1 1 1 0 0] + [15 4 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0]] + + +Completing the BDM model +~~~~~~~~~~~~~~~~~~~~~~~~ + +We are now ready to finish up the BDM model. To reproduce Figure 6(a) in +`*Lintusaari at al 2016* `__ +*[2]*, let's add different summaries and discrepancies to the model and +run the inference for each of them: + +.. code:: python + + def T1(clusters): + clusters = np.atleast_2d(clusters) + return np.sum(clusters > 0, 1)/np.sum(clusters, 1) + + def T2(clusters, n=20): + clusters = np.atleast_2d(clusters) + return 1 - np.sum((clusters/n)**2, axis=1) + + # Add the different distances to the model + elfi.Summary(T1, bdm_node, name='T1') + elfi.Distance('minkowski', m['T1'], p=1, name='d_T1') + + elfi.Summary(T2, bdm_node, name='T2') + elfi.Distance('minkowski', m['T2'], p=1, name='d_T2') + + elfi.Distance('minkowski', m['sim'], p=1, name='d_sim') + + + + +.. parsed-literal:: + + Distance(name='d_sim') + + + +.. code:: python + + elfi.draw(m) + + + + +.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.5/usage/external_files/external_24_0.svg + + + +.. code:: python + + # Save parameter and simulation results in memory to speed up the later inference + pool = elfi.OutputPool(['alpha', 'sim']) + # Fix a seed + seed = 20170511 + + rej = elfi.Rejection(m, 'd_T1', batch_size=10000, pool=pool, seed=seed) + %time T1_res = rej.sample(5000, n_sim=int(1e5)) + + rej = elfi.Rejection(m, 'd_T2', batch_size=10000, pool=pool, seed=seed) + %time T2_res = rej.sample(5000, n_sim=int(1e5)) + + rej = elfi.Rejection(m, 'd_sim', batch_size=10000, pool=pool, seed=seed) + %time sim_res = rej.sample(5000, n_sim=int(1e5)) + + +.. parsed-literal:: + + CPU times: user 4.19 s, sys: 60 ms, total: 4.25 s + Wall time: 5.19 s + CPU times: user 24 ms, sys: 4 ms, total: 28 ms + Wall time: 26.3 ms + CPU times: user 28 ms, sys: 0 ns, total: 28 ms + Wall time: 28.9 ms + + +.. code:: python + + # Load a precomputed posterior based on an analytic solution (see Lintusaari et al 2016) + matdata = sio.loadmat('./resources/bdm.mat') + x = matdata['likgrid'].reshape(-1) + posterior_at_x = matdata['post'].reshape(-1) + + # Plot the reference + plt.figure() + plt.plot(x, posterior_at_x, c='k') + + # Plot the different curves + for res, d_node, c in ([sim_res, 'd_sim', 'b'], [T1_res, 'd_T1', 'g'], [T2_res, 'd_T2', 'r']): + alphas = res.outputs['alpha'] + dists = res.outputs[d_node] + # Use gaussian kde to make the curves look nice. Note that this tends to benefit the algorithm 1 + # a lot as it ususally has only a very few accepted samples with 100000 simulations + kde = ss.gaussian_kde(alphas[dists<=0]) + plt.plot(x, kde(x), c=c) + + plt.legend(['reference', 'algorithm 1', 'algorithm 2, T1\n(eps=0)', 'algorithm 2, T2\n(eps=0)']) + plt.xlim([-.2, 1.2]); + print('Results after 100000 simulations. Compare to figure 6(a) in Lintusaari et al. 2016.') + + +.. parsed-literal:: + + Results after 100000 simulations. Compare to figure 6(a) in Lintusaari et al. 2016. + + + +.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.5/usage/external_files/external_26_1.png + + +Interfacing with R +------------------ + +It is possible to run R scripts in command line for example with +`Rscript `__. +However, in Python it may be more convenient to use +`rpy2 `__, which allows convenient access to +the functionality of R from within Python. You can install it with +``pip install rpy2``. + +Here we demonstrate how to calculate the summary statistics used in the +ELFI tutorial (autocovariances) using R's ``acf`` function for the MA2 +model. + +.. code:: python + + import rpy2.robjects as robj + from rpy2.robjects import numpy2ri as np2ri + + # Converts numpy arrays automatically + np2ri.activate() + +.. Note:: See this issue_ if you get a `undefined symbol: PC` error in the import after installing rpy2 and you are using Anaconda. + +.. _issue: https://github.com/ContinuumIO/anaconda-issues/issues/152 + +Let's create a Python function that wraps the R commands (please see the +documentation of `rpy2 `__ for details): + +.. code:: python + + robj.r(''' + # create a function `f` + f <- function(x, lag=1) { + ac = acf(x, plot=FALSE, type="covariance", lag.max=lag, demean=FALSE) + ac[['acf']][lag+1] + } + ''') + + f = robj.globalenv['f'] + + def autocovR(x, lag=1): + x = np.atleast_2d(x) + apply = robj.r['apply'] + ans = apply(x, 1, f, lag=lag) + return np.atleast_1d(ans) + +.. code:: python + + # Test it + autocovR(np.array([[1,2,3,4], [4,5,6,7]]), 1) + + + + +.. parsed-literal:: + + array([ 5., 23.]) + + + +Load a ready made MA2 model: + +.. code:: python + + ma2 = elfi.examples.ma2.get_model(seed_obs=4) + elfi.draw(ma2) + + + + +.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.5/usage/external_files/external_35_0.svg + + + +Replace the summaries S1 and S2 with our R autocovariance function. + +.. code:: python + + # Replace with R autocov + S1 = elfi.Summary(autocovR, ma2['MA2'], 1) + S2 = elfi.Summary(autocovR, ma2['MA2'], 2) + ma2['S1'].become(S1) + ma2['S2'].become(S2) + + # Run the inference + rej = elfi.Rejection(ma2, 'd', batch_size=1000, seed=seed) + rej.sample(100) + + + + +.. parsed-literal:: + + Method: Rejection + Number of posterior samples: 100 + Number of simulations: 10000 + Threshold: 0.11 + Posterior means: t1: 0.597, t2: 0.168 + + + +Interfacing with MATLAB +----------------------- + +There are a number of options for running MATLAB (or Octave) scripts +from within Python. Here, evaluating the distance is demonstrated with a +MATLAB function using the official `MATLAB Python cd +API `__. +(Tested with MATLAB 2016b.) + +.. code:: python + + import matlab.engine + +A MATLAB session needs to be started (and stopped) separately: + +.. code:: python + + eng = matlab.engine.start_matlab() # takes a while... + +Similarly as with R, we have to write a piece of code to interface +between MATLAB and Python: + +.. code:: python + + def euclidean_M(x, y): + # MATLAB array initialized with Python's list + ddM = matlab.double((x-y).tolist()) + + # euclidean distance + dM = eng.sqrt(eng.sum(eng.power(ddM, 2.0), 2)) + + # Convert back to numpy array + d = np.atleast_1d(dM).reshape(-1) + return d + +.. code:: python + + # Test it + euclidean_M(np.array([[1,2,3], [6,7,8], [2,2,3]]), np.array([2,2,2])) + + + + +.. parsed-literal:: + + array([ 1.41421356, 8.77496439, 1. ]) + + + +Load a ready made MA2 model: + +.. code:: python + + ma2M = elfi.examples.ma2.get_model(seed_obs=4) + elfi.draw(ma2M) + + + + +.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.5/usage/external_files/external_47_0.svg + + + +Replace the summaries S1 and S2 with our R autocovariance function. + +.. code:: python + + # Replace with Matlab distance implementation + d = elfi.Distance(euclidean_M, ma2M['S1'], ma2M['S2']) + ma2M['d'].become(d) + + # Run the inference + rej = elfi.Rejection(ma2M, 'd', batch_size=1000, seed=seed) + rej.sample(100) + + + + +.. parsed-literal:: + + Method: Rejection + Number of posterior samples: 100 + Number of simulations: 10000 + Threshold: 0.111 + Posterior means: t1: 0.6, t2: 0.169 + + + +Finally, don't forget to quit the MATLAB session: + +.. code:: python + + eng.quit() + +Verdict +------- + +We showed here a few examples of how to incorporate non Python +operations to ELFI models. There are multiple other ways to achieve the +same results and even make the wrapping more efficient. + +Wrapping often introduces some overhead to the evaluation of the +generative model. In many cases however this is not an issue since the +operations are usually expensive by themselves making the added overhead +insignificant. + +References +~~~~~~~~~~ + +- [1] Jarno Lintusaari, Michael U. Gutmann, Ritabrata Dutta, Samuel + Kaski, Jukka Corander; Fundamentals and Recent Developments in + Approximate Bayesian Computation. Syst Biol 2017; 66 (1): e66-e82. + doi: 10.1093/sysbio/syw077 +- [2] Tanaka, Mark M., et al. "Using approximate Bayesian computation + to estimate tuberculosis transmission parameters from genotype data." + Genetics 173.3 (2006): 1511-1520. diff --git a/docs/usage/implementing-methods.rst b/docs/usage/implementing-methods.rst new file mode 100644 index 00000000..142470c6 --- /dev/null +++ b/docs/usage/implementing-methods.rst @@ -0,0 +1,2 @@ +Implementing new methods +======================== \ No newline at end of file diff --git a/docs/usage/parallelization.rst b/docs/usage/parallelization.rst new file mode 100644 index 00000000..cdcc71da --- /dev/null +++ b/docs/usage/parallelization.rst @@ -0,0 +1,229 @@ + +Parallelization +=============== + +Behind the scenes, ELFI can automatically parallelize the computational +inference via different clients. Currently ELFI has two clients: + +- ``elfi.clients.native`` (activated by default): does not parallelize + but makes it easy to test and debug your code. +- ``elfi.clients.ipyparallel``: + `ipyparallel `__ based client + that can parallelize from multiple cores up to a distributed cluster. + +We will show in this tutorial how to activate and use the +``ipyparallel`` client with ELFI. This tutorial is generated from a +`Jupyter `__ notebook that can be found +`here `__. + +Activating parallelization +-------------------------- + +To activate the ``ipyparallel`` client in ELFI you just need to import +it: + +.. code:: python + + import elfi + # This activates the parallelization with ipyparallel + import elfi.clients.ipyparallel + +Starting a local ipcluster +-------------------------- + +Before you can actually run things in parallel you also need to start an +``ipyparallel`` cluster. Below is an example of how to start a local +cluster to the background using 4 CPU cores: + +.. code:: python + + !ipcluster start -n 4 --daemon + + # This is here just to ensure that ipcluster has enough time to start properly before continuing + import time + time.sleep(10) + +.. note:: The exclamation mark above is a Jupyter syntax for executing shell commands. You can run the same command in your terminal without the exclamation mark. + +.. tip:: Please see the [ipyparallel documentation](https://ipyparallel.readthedocs.io/en/latest/intro.html#getting-started) for more information and details for setting up and using ipyparallel clusters. + +Running parallel inference +-------------------------- + +We will run parallel inference for the MA2 model introduced in the +`tutorial `__. A ready made model can be imported from +``elfi.examples``: + +.. code:: python + + from elfi.examples import ma2 + model = ma2.get_model() + + elfi.draw(model) + + + + +.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.5/usage/parallelization_files/parallelization_8_0.svg + + + +Otherwise everything should be familiar, and ELFI handles everything for +you regarding the parallelization. + +.. code:: python + + rej = elfi.Rejection(model, 'd', batch_size=10000, seed=20170530) + +When running the next command, take a look at the system monitor of your +operating system; it should show 4 (or whatever number you gave the +``ipcluster start`` command) Python processes doing heavy computation +simultaneously. + +.. code:: python + + %time result = rej.sample(5000, n_sim=int(5e6)) # 5 million simulations + + +.. parsed-literal:: + + CPU times: user 3.07 s, sys: 168 ms, total: 3.24 s + Wall time: 15.2 s + + +The ``Result`` object is also just like in the basic case: + +.. code:: python + + result.summary + + +.. parsed-literal:: + + Method: Rejection + Number of posterior samples: 5000 + Number of simulations: 5000000 + Threshold: 0.0428 + Posterior means: t1: 0.771, t2: 0.513 + + +.. code:: python + + import matplotlib.pyplot as plt + result.plot_pairs() + plt.show() + + + +.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.5/usage/parallelization_files/parallelization_15_0.png + + +To summarize, the only thing that needed to be changed from the basic +scenario was enabling the ``ipyparallel`` client. + +Working interactively +--------------------- + +All imports and definitions must be visible to all ``ipyparallel`` +engines. You can ensure this by writing a script file that has all the +definitions in it. In a distributed setting, this file must be present +in all remote workers running an ``ipyparallel`` engine. + +However, you may wish to experiment in an interactive session, using +e.g. a jupyter notebook. ``ipyparallel`` makes it possible to +interactively define functions for ELFI model and send them to workers. +This is especially useful if you work from a jupyter notebook. We will +show a few examples. More information can be found from ``ipyparallel`` +documentation. + +In interactive sessions, you can change the model with built-in +functionality without problems: + +.. code:: python + + d2 = elfi.Distance('cityblock', model['S1'], model['S2'], p=1) + + rej2 = elfi.Rejection(d2, batch_size=10000) + result2 = rej2.sample(1000, quantile=0.01) + +But let's say you want to use your very own distance function in a +jupyter notebook: + +.. code:: python + + def my_distance(x, y): + # Note that interactively defined functions must use full module names, e.g. numpy instead of np + return numpy.sum((x-y)**2, axis=1) + + d3 = elfi.Distance(my_distance, model['S1'], model['S2']) + rej3 = elfi.Rejection(d3, batch_size=10000) + +This function definition is not automatically visible for the +``ipyparallel`` engines if it is not defined in a physical file. The +engines run in different processes and will not see interactively +defined objects and functions. The below would therefore fail: + +.. code:: python + + # This will fail if you try it! + # result3 = rej3.sample(1000, quantile=0.01) + +Ipyparallel provides a way to manually ``push`` the new definition to +the scopes of the engines from interactive sessions. Because +``my_distance`` also uses ``numpy``, that must be imported in the +engines as well: + +.. code:: python + + # Get the ipyparallel client + ipyclient = elfi.get_client().ipp_client + + # Import numpy in the engines (note that you cannot use "as" abbreviations, but must use plain imports) + with ipyclient[:].sync_imports(): + import numpy + + # Then push my_distance to the engines + ipyclient[:].push({'my_distance': my_distance}); + + +.. parsed-literal:: + + importing numpy on engine(s) + + +The above may look a bit cumbersome, but now this works: + +.. code:: python + + rej3.sample(1000, quantile=0.01) # now this works + + + + +.. parsed-literal:: + + Method: Rejection + Number of posterior samples: 1000 + Number of simulations: 100000 + Threshold: 0.0189 + Posterior means: t1: 0.771, t2: 0.483 + + + +However, a simpler solution to cases like this may be to define your +functions in external scripts (see ``elfi.examples.ma2``) and have the +module files be available in the folder where you run your ipyparallel +engines. + +Remember to stop the ipcluster when done +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code:: python + + !ipcluster stop + + +.. parsed-literal:: + + 2017-05-30 18:21:46.329 [IPClusterStop] Stopping cluster [pid=3011921] with [signal=] + diff --git a/docs/usage/tutorial.rst b/docs/usage/tutorial.rst new file mode 100644 index 00000000..11f943c2 --- /dev/null +++ b/docs/usage/tutorial.rst @@ -0,0 +1,1128 @@ + +ELFI tutorial +============= + +This tutorial covers the basics of using ELFI, i.e. how to make models, +save results for later use and run different inference algorithms. +Please see also our other tutorials for +`parallelization `__ and using `non-Python +operations `__ in ELFI models. This tutorial is generated +from a `Jupyter `__ notebook that can be found +`here `__. + +Let's begin by importing libraries that we will use and specify some +settings. + +.. code:: python + + import numpy as np + import scipy.stats + import matplotlib + import matplotlib.pyplot as plt + + %matplotlib inline + + import logging + logging.basicConfig(level=logging.INFO) + + # Set an arbitrary global seed to keep the randomly generated quantities the same + np.random.seed(20170530) + +Inference with ELFI: case MA(2) model +------------------------------------- + +Throughout this tutorial we will use the 2nd order moving average model +MA(2) as an example. MA(2) is a common model used in univariate time +series analysis. Assuming zero mean it can be written as + +.. math:: + + + y_t = w_t + \theta_1 w_{t-1} + \theta_2 w_{t-2}, + +where :math:`\theta_1, \theta_2 \in \mathbb{R}` and +:math:`(w_k)_{k\in \mathbb{Z}} \sim N(0,1)` represents an independent +and identically distributed sequence of white noise. + +The observed data and the inference problem +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +In this tutorial, our task is to infer the parameters +:math:`\theta_1, \theta_2` given a sequence of 100 observations +:math:`y` that originate from an MA(2) process. Let's define the MA(2) +simulator as a Python function: + +.. code:: python + + def MA2(t1, t2, n_obs=100, batch_size=1, random_state=None): + # Make inputs 2d arrays for numpy broadcasting with w + t1 = np.asanyarray(t1).reshape((-1, 1)) + t2 = np.asanyarray(t2).reshape((-1, 1)) + random_state = random_state or np.random + + w = random_state.randn(batch_size, n_obs+2) # i.i.d. sequence ~ N(0,1) + x = w[:, 2:] + t1*w[:, 1:-1] + t2*w[:, :-2] + return x + +Above, ``t1``, ``t2``, and ``n_obs`` are the arguments specific to the +MA2 process. The latter two, ``batch_size`` and ``random_state`` are +ELFI specific keyword arguments. The ``batch_size`` argument tells how +many simulations are needed. The ``random_state`` argument is for +generating random quantities in your simulator. It is a +``numpy.RandomState`` object that has all the same methods as +``numpy.random`` module has. It is used for ensuring consistent results +and handling random number generation in parallel settings. + +Vectorization +~~~~~~~~~~~~~ + +What is the purpose of the ``batch_size`` argument? In ELFI, operations +are vectorized, meaning that instead of simulating a single MA2 sequence +at a time, we simulate a batch of them. A vectorized function takes +vectors as inputs, and computes the output for each element in the +vector. Vectorization is a way to make operations efficient in Python. +Above we rely on numpy to carry out the vectorized calculations. + +In this case the arguments ``t1`` and ``t2`` are going to be vectors of +length ``batch_size`` and the method returns a 2d array with the +simulations on the rows. Notice that for convenience, the funtion also +works with scalars that are first converted to vectors. + +.. note:: there is a built-in tool (`elfi.tools.vectorize`) in ELFI to vectorize operations that are not vectorized. It is basically a for loop wrapper. + +.. Important:: in order to guarantee a consistent state of pseudo-random number generation, the simulator must have `random_state` as a keyword argument for reading in a `numpy.RandomState` object. + +Let's now use this simulator to create toy observations. We will use +parameter values :math:`\theta_1=0.6, \theta_2=0.2` as in `*Marin et al. +(2012)* `__ +and then try to infer these parameter values back based on the toy +observed data alone. + +.. code:: python + + # true parameters + t1_true = 0.6 + t2_true = 0.2 + + y_obs = MA2(t1_true, t2_true) + + # Plot the observed sequence + plt.figure(figsize=(11, 6)); + plt.plot(y_obs.ravel()); + + # To illustrate the stochasticity, let's plot a couple of more observations with the same true parameters: + plt.plot(MA2(t1_true, t2_true).ravel()); + plt.plot(MA2(t1_true, t2_true).ravel()); + + + +.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.5/usage/tutorial_files/tutorial_9_0.png + + +Approximate Bayesian Computation +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Standard statistical inference methods rely on the use of the +*likelihood* function. Given a configuration of the parameters, the +likelihood function quantifies how likely it is that values of the +parameters produced the observed data. In our simple example case above +however, evaluating the likelihood is difficult due to the unobserved +latent sequence (variable ``w`` in the simulator code). In many real +world applications the likelihood function is not available or it is too +expensive to evaluate preventing the use of traditional inference +methods. + +One way to approach this problem is to use Approximate Bayesian +Computation (ABC) which is a statistically based method replacing the +use of the likelihood function with a simulator of the data. Loosely +speaking, it is based on the intuition that similar data is likely to +have been produced by similar parameters. Looking at the picture above, +in essence we would keep simulating until we have found enough sequences +that are similar to the observed sequence. Although the idea may appear +inapplicable for the task at hand, you will soon see that it does work. +For more information about ABC, please see e.g. + +- `Lintusaari, J., Gutmann, M. U., Dutta, R., Kaski, S., and Corander, + J. (2016). Fundamentals and recent developments in approximate + Bayesian computation. *Systematic Biology*, doi: + 10.1093/sysbio/syw077. `__ + +- `Marin, J.-M., Pudlo, P., Robert, C. P., and Ryder, R. J. (2012). + Approximate Bayesian computational methods. *Statistics and + Computing*, + 22(6):1167–1180. `__ + +- https://en.wikipedia.org/wiki/Approximate\_Bayesian\_computation + +Defining the model +------------------ + +ELFI includes an easy to use generative modeling syntax, where the +generative model is specified as a directed acyclic graph +(`DAG `__). This +provides an intuitive means to describe rather complex dependencies +conveniently. Often the target of the generative model is a distance +between the simulated and observed data. To start creating our model, we +will first import ELFI: + +.. code:: python + + import elfi + +As is usual in Bayesian statistical inference, we need to define *prior* +distributions for the unknown parameters :math:`\theta_1, \theta_2`. In +ELFI the priors can be any of the continuous and discrete distributions +available in ``scipy.stats`` (for custom priors, see +`below <#custom_prior>`__). For simplicity, let's start by assuming that +both parameters follow ``Uniform(0, 2)``. + +.. code:: python + + # a node is defined by giving a distribution from scipy.stats together with any arguments (here 0 and 2) + t1 = elfi.Prior(scipy.stats.uniform, 0, 2) + + # ELFI also supports giving the scipy.stats distributions as strings + t2 = elfi.Prior('uniform', 0, 2) + +Next, we define the *simulator* node with the ``MA2`` function above, +and give the priors to it as arguments. This means that the parameters +for the simulations will be drawn from the priors. Because we have the +observed data available for this node, we provide it here as well: + +.. code:: python + + Y = elfi.Simulator(MA2, t1, t2, observed=y_obs) + +But how does one compare the simulated sequences with the observed +sequence? Looking at the plot of just a few observed sequences above, a +direct pointwise comparison would probably not work very well: the three +sequences look quite different although they were generated with the +same parameter values. Indeed, the comparison of simulated sequences is +often the most difficult (and ad hoc) part of ABC. Typically one chooses +one or more summary statistics and then calculates the discrepancy +between those. + +Here, we will apply the intuition arising from the definition of the +MA(2) process, and use the autocovariances with lags 1 and 2 as the +summary statistics: + +.. code:: python + + def autocov(x, lag=1): + C = np.mean(x[:,lag:] * x[:,:-lag], axis=1) + return C + +As is familiar by now, a ``Summary`` node is defined by giving the +autocovariance function and the simulated data (which includes the +observed as well): + +.. code:: python + + S1 = elfi.Summary(autocov, Y) + S2 = elfi.Summary(autocov, Y, 2) # the optional keyword lag is given the value 2 + +Here, we choose the discrepancy as the common Euclidean L2-distance. +ELFI can use many common distances directly from +``scipy.spatial.distance`` like this: + +.. code:: python + + # Finish the model with the final node that calculates the squared distance (S1_sim-S1_obs)**2 + (S2_sim-S2_obs)**2 + d = elfi.Distance('euclidean', S1, S2) + +One may wish to use a distance function that is unavailable in +``scipy.spatial.distance``. ELFI supports defining a custom +distance/discrepancy functions as well (see the documentation for +``elfi.Distance`` and ``elfi.Discrepancy``). + +Now that the inference model is defined, ELFI can visualize the model as +a DAG. + +.. code:: python + + elfi.draw(d) # just give it a node in the model, or the model itself (d.model) + + + + +.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.5/usage/tutorial_files/tutorial_26_0.svg + + + +.. note:: You will need the Graphviz_ software as well as the graphviz `Python package`_ (https://pypi.python.org/pypi/graphviz) for drawing this. The software is already installed in many unix-like OS. + +.. _Graphviz: http://www.graphviz.org +.. _`Python package`: https://pypi.python.org/pypi/graphviz + +Modifying the model +------------------- + +Although the above definition is perfectly valid, let's use the same +priors as in `*Marin et al. +(2012)* `__ +that guarantee that the problem will be identifiable (loosely speaking, +the likelihood willl have just one mode). Marin et al. used priors for +which :math:`-2<\theta_1<2` with :math:`\theta_1+\theta_2>-1` and +:math:`\theta_1-\theta_2<1` i.e. the parameters are sampled from a +triangle (see below). + +Custom priors +~~~~~~~~~~~~~ + +In ELFI, custom distributions can be defined similar to distributions in +``scipy.stats`` (i.e. they need to have at least the ``rvs`` method +implemented for the simplest algorithms). To be safe they can inherit +``elfi.Distribution`` which defines the methods needed. In this case we +only need these for sampling, so implementing a static ``rvs`` method +suffices. As was in the context of simulators, it is important to accept +the keyword argument ``random_state``, which is needed for ELFI's +internal book-keeping of pseudo-random number generation. Also the +``size`` keyword is needed (which in the simple cases is the same as the +``batch_size`` in the simulator definition). + +.. code:: python + + # define prior for t1 as in Marin et al., 2012 with t1 in range [-b, b] + class CustomPrior_t1(elfi.Distribution): + def rvs(b, size=1, random_state=None): + u = scipy.stats.uniform.rvs(loc=0, scale=1, size=size, random_state=random_state) + t1 = np.where(u<0.5, np.sqrt(2.*u)*b-b, -np.sqrt(2.*(1.-u))*b+b) + return t1 + + # define prior for t2 conditionally on t1 as in Marin et al., 2012, in range [-a, a] + class CustomPrior_t2(elfi.Distribution): + def rvs(t1, a, size=1, random_state=None): + locs = np.maximum(-a-t1, t1-a) + scales = a - locs + t2 = scipy.stats.uniform.rvs(loc=locs, scale=scales, size=size, random_state=random_state) + return t2 + +These indeed sample from a triangle: + +.. code:: python + + t1_1000 = CustomPrior_t1.rvs(2, 1000) + t2_1000 = CustomPrior_t2.rvs(t1_1000, 1, 1000) + plt.scatter(t1_1000, t2_1000, s=4, edgecolor='none'); + # plt.plot([0, 2, -2, 0], [-1, 1, 1, -1], 'b') # outlines of the triangle + + + +.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.5/usage/tutorial_files/tutorial_32_0.png + + +Let's change the earlier priors to the new ones in the inference model: + +.. code:: python + + t1.become(elfi.Prior(CustomPrior_t1, 2)) + t2.become(elfi.Prior(CustomPrior_t2, t1, 1)) + + elfi.draw(d) + + + + +.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.5/usage/tutorial_files/tutorial_34_0.svg + + + +Note that ``t2`` now depends on ``t1``. Yes, ELFI supports hierarchy. + +Inference with rejection sampling +--------------------------------- + +The simplest ABC algorithm samples parameters from their prior +distributions, runs the simulator with these and compares them to the +observations. The samples are either accepted or rejected depending on +how large the distance is. The accepted samples represent samples from +the approximate posterior distribution. + +In ELFI, ABC methods are initialized either with a node giving the +distance, or with the ``ElfiModel`` object and the name of the distance +node. Depending on the inference method, additional arguments may be +accepted or required. + +A common optional keyword argument, accepted by all inference methods, +``batch_size`` defines how many simulations are performed in each +passing through the graph. + +Another optional keyword is the seed. This ensures that the outcome will +be always the same for the same data and model. If you leave it out, a +random seed will be taken. + +.. code:: python + + seed = 20170530 + rej = elfi.Rejection(d, batch_size=10000, seed=seed) + +.. note:: In Python, doing many calculations with a single function call can potentially save a lot of CPU time, depending on the operation. For example, here we draw 10000 samples from `t1`, pass them as input to `t2`, draw 10000 samples from `t2`, and then use these both to run 10000 simulations and so forth. All this is done in one passing through the graph and hence the overall number of function calls is reduced 10000-fold. However, this does not mean that batches should be as big as possible, since you may run out of memory, the fraction of time spent in function call overhead becomes insignificant, and many algorithms operate in multiples of `batch_size`. Furthermore, the `batch_size` is a crucial element for efficient parallelization (see the notebook on parallelization). + +After the ABC method has been initialized, samples can be drawn from it. +By default, rejection sampling in ELFI works in ``quantile`` mode i.e. a +certain quantile of the samples with smallest discrepancies is accepted. +The ``sample`` method requires the number of output samples as a +parameter. Note that the simulator is then run ``(N/quantile)`` times. +(Alternatively, the same behavior can be achieved by saying +``n_sim=1000000``.) + +The IPython magic command ``%time`` is used here to give you an idea of +runtime on a typical personal computer. We will turn interactive +visualization on so that if you run this on a notebook you will see the +posterior forming from a prior distribution. In this case most of the +time is spent in drawing. + +.. code:: python + + N = 10000 + + vis = dict(xlim=[-2,2], ylim=[-1,1]) + + # You can give the sample method a `vis` keyword to see an animation how the prior transforms towards the + # posterior with a decreasing threshold (interactive visualization will slow it down a bit though). + %time result = rej.sample(N, quantile=0.01, vis=vis) + + + +.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.5/usage/tutorial_files/tutorial_41_0.png + + + +.. raw:: html + + Threshold: 0.11621562954973891 + + +.. parsed-literal:: + + CPU times: user 31.6 s, sys: 916 ms, total: 32.5 s + Wall time: 32.4 s + + +The ``sample`` method returns a ``Result`` object, which contains +several attributes and methods. Most notably the attribute ``samples`` +contains an ``OrderedDict`` (i.e. an ordered Python dictionary) of the +posterior numpy arrays for all the mnodel parameters (``elfi.Prior``\ s +in the model). For rejection sampling, other attributes include e.g. the +``threshold``, which is the threshold value resulting in the requested +quantile. + +.. code:: python + + result.samples['t1'].mean() + + + + +.. parsed-literal:: + + 0.5574475023785852 + + + +The ``Result`` object includes a convenient ``summary`` method: + +.. code:: python + + result.summary + + +.. parsed-literal:: + + Method: Rejection + Number of posterior samples: 10000 + Number of simulations: 1000000 + Threshold: 0.116 + Posterior means: t1: 0.557, t2: 0.221 + + +Rejection sampling can also be performed with using a threshold or total +number of simulations. Let's define here threshold. This means that all +draws from the prior for which the generated distance is below the +threshold will be accepted as samples. Note that the simulator will run +as long as it takes to generate the requested number of samples. + +.. code:: python + + %time result2 = rej.sample(N, threshold=0.2) + + print(result2) # the Result object's __str__ contains the output from summary() + + +.. parsed-literal:: + + CPU times: user 2.1 s, sys: 112 ms, total: 2.22 s + Wall time: 2.21 s + Method: Rejection + Number of posterior samples: 10000 + Number of simulations: 340000 + Threshold: 0.2 + Posterior means: t1: 0.555, t2: 0.219 + + + +Storing simulated data +---------------------- + +As the samples are already in numpy arrays, you can just say e.g. +``np.save('t1_data.npy', result.samples['t1'])`` to save them. However, +ELFI provides some additional functionality. You may define a *pool* for +storing all outputs of any node in the model (not just the accepted +samples). Let's save all outputs for ``t1``, ``t2``, ``S1`` and ``S2`` +in our model: + +.. code:: python + + pool = elfi.OutputPool(['t1', 't2', 'S1', 'S2']) + rej = elfi.Rejection(d, pool=pool) + + %time result3 = rej.sample(N, n_sim=1000000) + result3 + + +.. parsed-literal:: + + CPU times: user 7.04 s, sys: 8 ms, total: 7.05 s + Wall time: 7.05 s + + + + +.. parsed-literal:: + + Method: Rejection + Number of posterior samples: 10000 + Number of simulations: 1000000 + Threshold: 0.115 + Posterior means: t1: 0.556, t2: 0.218 + + + +The benefit of the pool is that you may reuse simulations without having +to resimulate them. Above we saved the summaries to the pool, so we can +change the distance node of the model without having to resimulate +anything. Let's do that. + +.. code:: python + + # Replace the current distance with a cityblock (manhattan) distance and recreate the inference + d.become(elfi.Distance('cityblock', S1, S2, p=1)) + rej = elfi.Rejection(d, pool=pool) + + %time result4 = rej.sample(N, n_sim=1000000) + result4 + + +.. parsed-literal:: + + CPU times: user 956 ms, sys: 0 ns, total: 956 ms + Wall time: 954 ms + + + + +.. parsed-literal:: + + Method: Rejection + Number of posterior samples: 10000 + Number of simulations: 1000000 + Threshold: 0.144 + Posterior means: t1: 0.557, t2: 0.219 + + + +Note the significant saving in time, even though the total number of +considered simulations stayed the same. + +We can also continue the inference by increasing the total number of +simulations and only have to simulate the new ones: + +.. code:: python + + %time result5 = rej.sample(N, n_sim=1200000) + result5 + + +.. parsed-literal:: + + CPU times: user 2.33 s, sys: 8 ms, total: 2.34 s + Wall time: 2.33 s + + + + +.. parsed-literal:: + + Method: Rejection + Number of posterior samples: 10000 + Number of simulations: 1200000 + Threshold: 0.131 + Posterior means: t1: 0.556, t2: 0.22 + + + +Above the results were saved into a python dictionary. If you store a +lot of data to dictionaries, you will eventually run out of memory. +Instead you can save the outputs to standard numpy .npy files: + +.. code:: python + + arraypool = elfi.store.ArrayPool(['t1', 't2', 'Y', 'd'], basepath='./output') + rej = elfi.Rejection(d, pool=arraypool) + %time result5 = rej.sample(100, threshold=0.3) + + +.. parsed-literal:: + + CPU times: user 32 ms, sys: 8 ms, total: 40 ms + Wall time: 36.7 ms + + +This stores the simulated data in binary ``npy`` format under +``arraypool.path``, and can be loaded with ``np.load``. + +.. code:: python + + # Let's flush the outputs to disk (alternatively you can close the pool) so that we can read them + # while we still have the arraypool open. + arraypool.flush() + + !ls $arraypool.path + + +.. parsed-literal:: + + d.npy t1.npy t2.npy Y.npy + + +Now lets load all the parameters ``t1`` that were generated with numpy: + +.. code:: python + + np.load(arraypool.path + '/t1.npy') + + + + +.. parsed-literal:: + + array([ 1.2228635 , 0.84295063, 1.52794226, ..., -0.15726344, + -0.72876666, -0.93158204]) + + + +You can delete the files with: + +.. code:: python + + arraypool.delete() + + !ls $arraypool.path # verify the deletion + + +.. parsed-literal:: + + ls: cannot access './output/arraypool/4213416233': No such file or directory + + +Visualizing the results +----------------------- + +Instances of ``Result`` contain methods for some basic plotting (these +are convenience methods to plotting functions defined under +``elfi.visualization``). + +For example one can plot the marginal distributions: + +.. code:: python + + result.plot_marginals(); + + + +.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.5/usage/tutorial_files/tutorial_65_0.png + + +Often "pairwise relationships" are more informative: + +.. code:: python + + result.plot_pairs(); + + + +.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.5/usage/tutorial_files/tutorial_67_0.png + + +Note that if working in a non-interactive environment, you can use e.g. +``plt.savefig('pairs.png')`` after an ELFI plotting command to save the +current figure to disk. + +Sequential Monte Carlo ABC +-------------------------- + +Rejection sampling is quite inefficient, as it does not learn from its +history. The sequential Monte Carlo (SMC) ABC algorithm does just that +by applying importance sampling: samples are *weighed* according to the +resulting discrepancies and the next *population* of samples is drawn +near to the previous using the weights as probabilities. + +For evaluating the weights, SMC ABC needs to be able to compute the +probability density of the generated parameters. In our MA2 example we +used custom priors, so we have to specify a ``pdf`` function by +ourselves. If we used standard priors, this step would not be needed. +Let's modify the prior distribution classes: + +.. code:: python + + # define prior for t1 as in Marin et al., 2012 with t1 in range [-b, b] + class CustomPrior_t1(elfi.Distribution): + def rvs(b, size=1, random_state=None): + u = scipy.stats.uniform.rvs(loc=0, scale=1, size=size, random_state=random_state) + t1 = np.where(u<0.5, np.sqrt(2.*u)*b-b, -np.sqrt(2.*(1.-u))*b+b) + return t1 + + def pdf(x, b): + p = 1./b - np.abs(x) / (b*b) + p = np.where(p < 0., 0., p) # disallow values outside of [-b, b] (affects weights only) + return p + + + # define prior for t2 conditionally on t1 as in Marin et al., 2012, in range [-a, a] + class CustomPrior_t2(elfi.Distribution): + def rvs(t1, a, size=1, random_state=None): + locs = np.maximum(-a-t1, t1-a) + scales = a - locs + t2 = scipy.stats.uniform.rvs(loc=locs, scale=scales, size=size, random_state=random_state) + return t2 + + def pdf(x, t1, a): + locs = np.maximum(-a-t1, t1-a) + scales = a - locs + p = scipy.stats.uniform.pdf(x, loc=locs, scale=scales) + p = np.where(scales>0., p, 0.) # disallow values outside of [-a, a] (affects weights only) + return p + + + # Redefine the priors + t1.become(elfi.Prior(CustomPrior_t1, 2, model=t1.model)) + t2.become(elfi.Prior(CustomPrior_t2, t1, 1)) + +Run SMC ABC +~~~~~~~~~~~ + +In ELFI, one can setup a SMC ABC sampler just like the Rejection +sampler: + +.. code:: python + + smc = elfi.SMC(d, batch_size=10000, seed=seed) + +For sampling, one has to define the number of output samples, the number +of populations and a *schedule* i.e. a list of quantiles to use for each +population. In essence, a population is just refined rejection sampling. + +.. code:: python + + N = 1000 + schedule = [0.7, 0.2, 0.05] + %time result_smc = smc.sample(N, schedule) + + +.. parsed-literal:: + + INFO:elfi.methods.methods:---------------- Starting round 0 ---------------- + INFO:elfi.methods.methods:---------------- Starting round 1 ---------------- + INFO:elfi.methods.methods:---------------- Starting round 2 ---------------- + + +.. parsed-literal:: + + CPU times: user 5.97 s, sys: 200 ms, total: 6.17 s + Wall time: 1.73 s + + +We can have summaries and plots of the results just like above: + +.. code:: python + + result_smc.summary + + +.. parsed-literal:: + + Method: SMC-ABC + Number of posterior samples: 1000 + Number of simulations: 180000 + Threshold: 0.0497 + Posterior means for final population: t1: 0.557, t2: 0.228 + + +The ``Result`` object returned by the SMC-ABC sampling contains also +some methods for investigating the evolution of populations, e.g.: + +.. code:: python + + result_smc.posterior_means_all_populations + + +.. parsed-literal:: + + Posterior means for population 0: t1: 0.544, t2: 0.229 + Posterior means for population 1: t1: 0.557, t2: 0.231 + Posterior means for population 2: t1: 0.557, t2: 0.228 + + + +.. code:: python + + result_smc.plot_marginals_all_populations(bins=25, figsize=(8, 2), fontsize=12) + + + +.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.5/usage/tutorial_files/tutorial_80_0.png + + + +.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.5/usage/tutorial_files/tutorial_80_1.png + + + +.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.5/usage/tutorial_files/tutorial_80_2.png + + +Obviously one still has direct access to the samples as well, which +allows custom plotting: + +.. code:: python + + n_populations = len(schedule) + fig, ax = plt.subplots(ncols=n_populations, sharex=True, sharey=True, figsize=(16,6)) + samples = [pop.samples_list for pop in result_smc.populations] + for ii in range(n_populations): + s = samples[ii] + ax[ii].scatter(s[0], s[1], s=5, edgecolor='none'); + ax[ii].set_title("Population {}".format(ii)); + ax[ii].plot([0, 2, -2, 0], [-1, 1, 1, -1], 'b') + ax[0].set_xlabel(result_smc.names_list[0]); + ax[0].set_ylabel(result_smc.names_list[1]); + ax[0].set_xlim([-2, 2]) + ax[0].set_ylim([-1, 1]); + + + +.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.5/usage/tutorial_files/tutorial_82_0.png + + +It can be seen that the populations iteratively concentrate more and +more around the true parameter values. + +Note that for the later populations some of the samples lie outside +allowed region. This is due to the SMC algorithm sampling near previous +samples, with *near* meaning a Gaussian distribution centered around +previous samples with variance as twice the weighted empirical variance. +However, the outliers carry zero weight, and have no effect on the +estimates. + +BOLFI +----- + +In practice inference problems often have a more complicated and +computationally heavy simulator than the model ``MA2`` here, and one +simply cannot run it for millions of times. The Bayesian Optimization +for Likelihood-Free Inference +`BOLFI `__ framework +is likely to prove useful in such situation: a statistical model (e.g. +`Gaussian process `__, +GP) is created for the discrepancy, and its minimum is inferred with +`Bayesian +optimization `__. +This approach typically reduces the number of required simulator calls +by several orders of magnitude. + +When dealing with a Gaussian process, it is advisable to take a +logarithm of the discrepancies in order to reduce the effect that high +discrepancies have on the GP. In ELFI such transformed node can be +created easily: + +.. code:: python + + log_d = elfi.Operation(np.log, d) + +As BOLFI is a more advanced inference method, its interface is also a +bit more involved. But not much: Using the same graphical model as +earlier, the inference could begin by defining a Gaussian process (GP) +model, for which we use the `GPy `__ +library. This could then be given via a keyword argument +``target_model``. In this case, we are happy with the default that ELFI +creates for us when we just give it each parameter some ``bounds``. + +Other notable arguments include the ``initial_evidence``, which defines +the number of initialization points sampled straight from the priors +before starting to optimize the acquisition of points, and +``update_interval`` which defines how often the GP hyperparameters are +optimized. + +.. code:: python + + bolfi = elfi.BOLFI(log_d, batch_size=5, initial_evidence=20, update_interval=10, + bounds=[(-2, 2), (-1, 1)], seed=seed) + +Sometimes you may have some samples readily available. You could then +initialize the GP model with a dictionary of previous results by giving +``initial_evidence=result1.outputs``. + +The BOLFI class can now try to ``fit`` the surrogate model (the GP) to +the relationship between parameter values and the resulting +discrepancies. We'll request 200 evidence points (including the +``initial_evidence`` defined above). + +.. code:: python + + %time bolfi.fit(n_evidence=200) + + +.. parsed-literal:: + + INFO:elfi.methods.methods:BOLFI: Fitting the surrogate model... + + +.. parsed-literal:: + + CPU times: user 42.7 s, sys: 620 ms, total: 43.4 s + Wall time: 13.9 s + + +Running this does not return anything currently, but internally the GP +is now fitted. + +Note that in spite of the very few simulator runs, fitting the model +took longer than any of the previous methods. Indeed, BOLFI is intended +for scenarios where the simulator takes a lot of time to run. + +The fitted ``target_model`` uses the GPy libarary, which can be +investigated further: + +.. code:: python + + bolfi.target_model + + + + +.. parsed-literal:: + + + Name : GP regression + Objective : 133.39773058984275 + Number of Parameters : 4 + Number of Optimization Parameters : 4 + Updates : True + Parameters: + GP_regression.  | value | constraints | priors + sum.rbf.variance  | 0.259297636885 | +ve | Ga(0.033, 1) + sum.rbf.lengthscale  | 0.607506322067 | +ve | Ga(1.3, 1) + sum.bias.variance  | 0.189445916354 | +ve | Ga(0.0082, 1) + Gaussian_noise.variance | 0.150210139296 | +ve | + + + +.. code:: python + + bolfi.plot_state(); + + + +.. parsed-literal:: + + + + + +.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.5/usage/tutorial_files/tutorial_95_1.png + + +It may be helpful to see the acquired parameter values and the resulting +discrepancies: + +.. code:: python + + bolfi.plot_discrepancy(); + + + +.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.5/usage/tutorial_files/tutorial_97_0.png + + +Note the high number of points at parameter bounds. These could probably +be decreased by lowering the covariance of the noise added to acquired +points, defined by the optional ``acq_noise_cov`` argument for the BOLFI +constructor. Another possibility could be to `add virtual derivative +observations at the borders `__, +though not yet implemented in ELFI. + +We can now infer the BOLFI posterior (please see the +`paper `__ for +details). The method accepts a threshold parameter; if none is given, +ELFI will use the minimum value of discrepancy estimate mean. + +.. code:: python + + post = bolfi.infer_posterior() + + +.. parsed-literal:: + + INFO:elfi.methods.results:Using minimum value of discrepancy estimate mean (-0.9865) as threshold + + +We can get estimates for *maximum a posteriori* and *maximum likelihood* +easily: + +.. code:: python + + post.MAP, post.ML + + + + +.. parsed-literal:: + + ((array([ 0.57407864, 0.09641608]), array([[ 0.69314718]])), + (array([ 0.57407869, 0.09641603]), array([[ 0.69314718]]))) + + + +We can visualize the posterior directly: + +.. code:: python + + post.plot() + + + +.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.5/usage/tutorial_files/tutorial_103_0.png + + +Finally, samples from the posterior can be acquired with an MCMC sampler +(note that depending on the smoothness of the GP approximation, this may +be slow): + +.. code:: python + + # bolfi.model.computation_context.seed = 10 + %time result_BOLFI = bolfi.sample(1000, target_prob=0.9) + + +.. parsed-literal:: + + INFO:elfi.methods.results:Using minimum value of discrepancy estimate mean (-0.9865) as threshold + INFO:elfi.methods.mcmc:NUTS: Performing 1000 iterations with 500 adaptation steps. + INFO:elfi.methods.mcmc:NUTS: Iterations performed: 100/1000... + INFO:elfi.methods.mcmc:NUTS: Iterations performed: 200/1000... + INFO:elfi.methods.mcmc:NUTS: Iterations performed: 300/1000... + INFO:elfi.methods.mcmc:NUTS: Iterations performed: 400/1000... + INFO:elfi.methods.mcmc:NUTS: Iterations performed: 500/1000... + INFO:elfi.methods.mcmc:NUTS: Adaptation/warmup finished. Sampling... + INFO:elfi.methods.mcmc:NUTS: Iterations performed: 600/1000... + INFO:elfi.methods.mcmc:NUTS: Iterations performed: 700/1000... + INFO:elfi.methods.mcmc:NUTS: Iterations performed: 800/1000... + INFO:elfi.methods.mcmc:NUTS: Iterations performed: 900/1000... + INFO:elfi.methods.mcmc:NUTS: Acceptance ratio: 0.215, Diverged proposals after warmup (i.e. n_adapt=500 steps): 8 + INFO:elfi.methods.mcmc:NUTS: Performing 1000 iterations with 500 adaptation steps. + INFO:elfi.methods.mcmc:NUTS: Iterations performed: 100/1000... + INFO:elfi.methods.mcmc:NUTS: Iterations performed: 200/1000... + INFO:elfi.methods.mcmc:NUTS: Iterations performed: 300/1000... + INFO:elfi.methods.mcmc:NUTS: Iterations performed: 400/1000... + INFO:elfi.methods.mcmc:NUTS: Iterations performed: 500/1000... + INFO:elfi.methods.mcmc:NUTS: Adaptation/warmup finished. Sampling... + INFO:elfi.methods.mcmc:NUTS: Iterations performed: 600/1000... + INFO:elfi.methods.mcmc:NUTS: Iterations performed: 700/1000... + INFO:elfi.methods.mcmc:NUTS: Iterations performed: 800/1000... + INFO:elfi.methods.mcmc:NUTS: Iterations performed: 900/1000... + INFO:elfi.methods.mcmc:NUTS: Acceptance ratio: 0.201, Diverged proposals after warmup (i.e. n_adapt=500 steps): 32 + INFO:elfi.methods.mcmc:NUTS: Performing 1000 iterations with 500 adaptation steps. + INFO:elfi.methods.mcmc:NUTS: Iterations performed: 100/1000... + INFO:elfi.methods.mcmc:NUTS: Iterations performed: 200/1000... + INFO:elfi.methods.mcmc:NUTS: Iterations performed: 300/1000... + INFO:elfi.methods.mcmc:NUTS: Iterations performed: 400/1000... + INFO:elfi.methods.mcmc:NUTS: Iterations performed: 500/1000... + INFO:elfi.methods.mcmc:NUTS: Adaptation/warmup finished. Sampling... + INFO:elfi.methods.mcmc:NUTS: Iterations performed: 600/1000... + INFO:elfi.methods.mcmc:NUTS: Iterations performed: 700/1000... + INFO:elfi.methods.mcmc:NUTS: Iterations performed: 800/1000... + INFO:elfi.methods.mcmc:NUTS: Iterations performed: 900/1000... + INFO:elfi.methods.mcmc:NUTS: Acceptance ratio: 0.223, Diverged proposals after warmup (i.e. n_adapt=500 steps): 10 + INFO:elfi.methods.mcmc:NUTS: Performing 1000 iterations with 500 adaptation steps. + INFO:elfi.methods.mcmc:NUTS: Iterations performed: 100/1000... + INFO:elfi.methods.mcmc:NUTS: Iterations performed: 200/1000... + INFO:elfi.methods.mcmc:NUTS: Iterations performed: 300/1000... + INFO:elfi.methods.mcmc:NUTS: Iterations performed: 400/1000... + INFO:elfi.methods.mcmc:NUTS: Iterations performed: 500/1000... + INFO:elfi.methods.mcmc:NUTS: Adaptation/warmup finished. Sampling... + INFO:elfi.methods.mcmc:NUTS: Iterations performed: 600/1000... + INFO:elfi.methods.mcmc:NUTS: Iterations performed: 700/1000... + INFO:elfi.methods.mcmc:NUTS: Iterations performed: 800/1000... + INFO:elfi.methods.mcmc:NUTS: Iterations performed: 900/1000... + INFO:elfi.methods.mcmc:NUTS: Acceptance ratio: 0.221, Diverged proposals after warmup (i.e. n_adapt=500 steps): 5 + + +.. parsed-literal:: + + 4 chains of 1000 iterations acquired. Effective sample size and Rhat for each parameter: + t1 649.78032882 1.00225844622 + t2 1037.40102821 1.00448229202 + CPU times: user 4min 11s, sys: 2.9 s, total: 4min 14s + Wall time: 1min 3s + + +The sampling algorithms may be fine-tuned with some parameters. If you +get a warning about diverged proposals, something may be wrong and +should be investigated. You can try rerunning the ``sample`` method with +a higher target probability ``target_prob`` during adaptation, as its +default 0.6 may be inadequate for a non-smooth GP, but this will slow +down the sampling. + +Now we finally have a ``Result`` object again, which has several +convenience methods: + +.. code:: python + + result_BOLFI + + + + +.. parsed-literal:: + + Method: BOLFI + Number of posterior samples: 2000 + Number of simulations: 200 + Threshold: -0.986 + Posterior means: t1: 0.599, t2: 0.0688 + + + +.. code:: python + + result_BOLFI.plot_traces(); + + + +.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.5/usage/tutorial_files/tutorial_108_0.png + + +The black vertical lines indicate the end of warmup, which by default is +half of the number of iterations. + +.. code:: python + + result_BOLFI.plot_marginals(); + + + +.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.5/usage/tutorial_files/tutorial_110_0.png + + +That's it! See the other documentation for more topics on e.g. using +external simulators and parallelization. diff --git a/elfi/__init__.py b/elfi/__init__.py index bce4cfe1..ea119606 100644 --- a/elfi/__init__.py +++ b/elfi/__init__.py @@ -1,16 +1,14 @@ # -*- coding: utf-8 -*- import elfi.clients.native +import elfi.methods.mcmc import elfi.model.tools as tools - from elfi.client import get_client, set_client from elfi.methods.methods import * from elfi.model.elfi_model import * +from elfi.model.extensions import ScipyLikeDistribution as Distribution from elfi.store import OutputPool from elfi.visualization.visualization import nx_draw as draw -from elfi.model.extensions import ScipyLikeDistribution as Distribution - -import elfi.mcmc __author__ = 'ELFI authors' __email__ = 'elfi-support@hiit.fi' diff --git a/elfi/graphical_model.py b/elfi/graphical_model.py index 3503b371..29ed9e01 100644 --- a/elfi/graphical_model.py +++ b/elfi/graphical_model.py @@ -64,7 +64,7 @@ def add_edge(self, parent_name, child_name, param_name=None): self.source_net.add_edge(parent_name, child_name, param=param_name) def update_node(self, node, updating_node): - """Updates `node` with `updating_node` in ElfiModel. + """Updates `node` with `updating_node` in the model. Node `node` gets the state (operation) and parents of the `updating_node`. The updating node is then removed from the graph. @@ -111,6 +111,7 @@ def nodes(self): return self.source_net.nodes() def copy(self): + """Returns a copy of the model""" return self.__copy__() def __copy__(self, *args, **kwargs): diff --git a/elfi/bo/__init__.py b/elfi/methods/bo/__init__.py similarity index 100% rename from elfi/bo/__init__.py rename to elfi/methods/bo/__init__.py diff --git a/elfi/bo/acquisition.py b/elfi/methods/bo/acquisition.py similarity index 96% rename from elfi/bo/acquisition.py rename to elfi/methods/bo/acquisition.py index 97ed396a..d15be4af 100644 --- a/elfi/bo/acquisition.py +++ b/elfi/methods/bo/acquisition.py @@ -1,12 +1,9 @@ -import sys import logging -import json -import numpy as np -from scipy.stats import truncnorm, uniform, multivariate_normal +import numpy as np +from scipy.stats import uniform, multivariate_normal -from elfi.bo.utils import approx_second_partial_derivative, sum_of_rbf_kernels, \ - stochastic_optimization +from elfi.methods.bo.utils import stochastic_optimization logger = logging.getLogger(__name__) diff --git a/elfi/bo/gpy_regression.py b/elfi/methods/bo/gpy_regression.py similarity index 100% rename from elfi/bo/gpy_regression.py rename to elfi/methods/bo/gpy_regression.py diff --git a/elfi/bo/utils.py b/elfi/methods/bo/utils.py similarity index 100% rename from elfi/bo/utils.py rename to elfi/methods/bo/utils.py diff --git a/elfi/mcmc.py b/elfi/methods/mcmc.py similarity index 100% rename from elfi/mcmc.py rename to elfi/methods/mcmc.py diff --git a/elfi/methods/methods.py b/elfi/methods/methods.py index 3dfd4399..06931864 100644 --- a/elfi/methods/methods.py +++ b/elfi/methods/methods.py @@ -1,23 +1,26 @@ import logging +from collections import OrderedDict +from functools import reduce, partial from math import ceil from operator import mul -from functools import reduce, partial -from toolz.functoolz import compose -from collections import OrderedDict +import matplotlib.pyplot as plt import numpy as np +from toolz.functoolz import compose import elfi.client -from elfi.utils import args_to_tuple -from elfi.store import OutputPool -from elfi.bo.gpy_regression import GPyRegression -from elfi.bo.acquisition import LCBSC -from elfi.bo.utils import stochastic_optimization +import elfi.visualization.visualization as vis +import elfi.visualization.interactive as visin +import elfi.methods.mcmc as mcmc + +from elfi.loader import get_sub_seed +from elfi.methods.bo.acquisition import LCBSC +from elfi.methods.bo.gpy_regression import GPyRegression +from elfi.methods.bo.utils import stochastic_optimization +from elfi.methods.results import BolfiPosterior, Result, ResultSMC, ResultBOLFI from elfi.methods.utils import GMDistribution, weighted_var -from elfi.methods.posteriors import BolfiPosterior from elfi.model.elfi_model import ComputationContext, NodeReference, Operation, ElfiModel -import elfi.visualization.interactive as visin -from elfi.results.result import * +from elfi.utils import args_to_tuple logger = logging.getLogger(__name__) @@ -192,7 +195,7 @@ def parameters(self): def batch_size(self): return self.model.computation_context.batch_size - def init_inference(self, *args, **kwargs): + def set_objective(self, *args, **kwargs): """This method is called when one wants to begin the inference. Set `self.state` and `self.objective` here for the inference. @@ -212,7 +215,7 @@ def extract_result(self): """ raise NotImplementedError - def update(self, batch, batch_index): + def _update(self, batch, batch_index): """ELFI calls this method when a new batch has been computed and the state of the inference should be updated with it. @@ -229,7 +232,7 @@ def update(self, batch, batch_index): """ raise NotImplementedError - def prepare_new_batch(self, batch_index): + def _prepare_new_batch(self, batch_index): """ELFI calls this method before submitting a new batch with an increasing index `batch_index`. This is an optional method to override. Use this if you have a need do do preparations, e.g. in Bayesian optimization algorithm, the next acquisition @@ -282,7 +285,7 @@ def infer(self, *args, vis=None, **kwargs): """ vis_opt = vis if isinstance(vis, dict) else {} - self.init_inference(*args, **kwargs) + self.set_objective(*args, **kwargs) while not self.finished: self.iterate() @@ -295,17 +298,20 @@ def infer(self, *args, vis=None, **kwargs): return self.extract_result() def iterate(self): - """Forward the inference one iteration. One iteration consists of processing the - the result of the next batch in succession. + """Forward the inference one iteration. + + This is a way to manually progress the inference. One iteration consists of + waiting and processing the result of the next batch in succession and possibly + submitting new batches. + Notes + ----- If the next batch is ready, it will be processed immediately and no new batches are submitted. - If the next batch is not ready, new batches will be submitted up to the - `_n_total_batches` or `max_parallel_batches` or until the next batch is complete. - - If there are no more submissions to do and the next batch has still not finished, - the method will wait for it's result. + New batches are submitted only while waiting for the next one to complete. There + will never be more batches submitted in parallel than the `max_parallel_batches` + setting allows. Returns ------- @@ -316,12 +322,12 @@ def iterate(self): # Submit new batches if allowed while self._allow_submit: batch_index = self.batches.next_index - batch = self.prepare_new_batch(batch_index) + batch = self._prepare_new_batch(batch_index) self.batches.submit(batch) # Handle the next batch in succession batch, batch_index = self.batches.wait_next() - self.update(batch, batch_index) + self._update(batch, batch_index) @property def finished(self): @@ -439,7 +445,7 @@ def __init__(self, model, discrepancy=None, outputs=None, **kwargs): outputs = self._ensure_outputs(outputs, model.parameters + [self.discrepancy]) super(Rejection, self).__init__(model, outputs, **kwargs) - def init_inference(self, n_samples, threshold=None, quantile=None, n_sim=None): + def set_objective(self, n_samples, threshold=None, quantile=None, n_sim=None): """ Parameters @@ -478,7 +484,7 @@ def init_inference(self, n_samples, threshold=None, quantile=None, n_sim=None): # Reset the inference self.batches.reset() - def update(self, batch, batch_index): + def _update(self, batch, batch_index): if self.state['samples'] is None: # Lazy initialization of the outputs dict self._init_samples_lazy(batch) @@ -591,6 +597,7 @@ def plot_state(self, **options): class SMC(Sampler): + """Sequential Monte Carlo ABC sampler""" def __init__(self, model, discrepancy=None, outputs=None, **kwargs): model, self.discrepancy = self._resolve_model(model, discrepancy) outputs = self._ensure_outputs(outputs, model.parameters + [self.discrepancy]) @@ -602,7 +609,7 @@ def __init__(self, model, discrepancy=None, outputs=None, **kwargs): self._populations = [] self._rejection = None - def init_inference(self, n_samples, thresholds): + def set_objective(self, n_samples, thresholds): self.objective.update(dict(n_samples=n_samples, n_batches=self.max_parallel_batches, round=len(thresholds) - 1, @@ -624,8 +631,8 @@ def extract_result(self): return result - def update(self, batch, batch_index): - self._rejection.update(batch, batch_index) + def _update(self, batch, batch_index): + self._rejection._update(batch, batch_index) if self._rejection.finished: self.batches.cancel_pending() @@ -637,7 +644,7 @@ def update(self, batch, batch_index): self._update_state() self._update_objective() - def prepare_new_batch(self, batch_index): + def _prepare_new_batch(self, batch_index): # Use the actual prior if self.state['round'] == 0: return @@ -659,8 +666,8 @@ def _new_round(self): seed=self.seed, max_parallel_batches=self.max_parallel_batches) - self._rejection.init_inference(self.objective['n_samples'], - threshold=self.current_population_threshold) + self._rejection.set_objective(self.objective['n_samples'], + threshold=self.current_population_threshold) def _extract_population(self): pop = self._rejection.extract_result() @@ -789,7 +796,7 @@ def __init__(self, model, target=None, outputs=None, batch_size=1, self.n_initial_evidence = initial_evidence self.update_interval = update_interval - def init_inference(self, n_evidence): + def set_objective(self, n_evidence): """You can continue BO by giving a larger n_evidence""" self.state['pending'] = OrderedDict() self.state['last_update'] = self.state.get('last_update') or self._n_precomputed @@ -811,7 +818,7 @@ def extract_result(self): return dict(samples=param_hat) - def update(self, batch, batch_index): + def _update(self, batch, batch_index): """Update the GP regression model of the target node. """ self.state['pending'].pop(batch_index, None) @@ -828,7 +835,7 @@ def update(self, batch, batch_index): self.state['n_batches'] += 1 self.state['n_sim'] += self.batch_size - def prepare_new_batch(self, batch_index): + def _prepare_new_batch(self, batch_index): if self._n_submitted_evidence < self.n_initial_evidence - self._n_precomputed: return @@ -1069,8 +1076,8 @@ def sample(self, n_samples, warmup=None, n_chains=4, threshold=None, initials=No # sampling is embarrassingly parallel, so depending on self.client this may parallelize for ii in range(n_chains): - seed = elfi.loader.get_sub_seed(random_state, ii) - tasks_ids.append(self.client.apply(elfi.mcmc.nuts, n_samples, initials[ii], posterior.logpdf, + seed = get_sub_seed(random_state, ii) + tasks_ids.append(self.client.apply(mcmc.nuts, n_samples, initials[ii], posterior.logpdf, posterior.grad_logpdf, n_adapt=warmup, seed=seed, **kwargs)) # get results from completed tasks or run sampling (client-specific) @@ -1084,7 +1091,7 @@ def sample(self, n_samples, warmup=None, n_chains=4, threshold=None, initials=No print("{} chains of {} iterations acquired. Effective sample size and Rhat for each parameter:" .format(n_chains, n_samples)) for ii, node in enumerate(self.parameters): - print(node, elfi.mcmc.eff_sample_size(chains[:, :, ii]), elfi.mcmc.gelman_rubin(chains[:, :, ii])) + print(node, mcmc.eff_sample_size(chains[:, :, ii]), mcmc.gelman_rubin(chains[:, :, ii])) self.target_model.is_sampling = False diff --git a/elfi/methods/posteriors.py b/elfi/methods/posteriors.py deleted file mode 100644 index 17da46c2..00000000 --- a/elfi/methods/posteriors.py +++ /dev/null @@ -1,212 +0,0 @@ -import logging -import numpy as np -import scipy as sp - -import matplotlib -import matplotlib.pyplot as plt - -from elfi.bo.utils import stochastic_optimization - -logger = logging.getLogger(__name__) - - -class BolfiPosterior(object): - """ - Container for the approximate posterior in the BOLFI framework, where the likelihood - is defined as - - L \propto F((h - \mu) / \sigma) - - where F is the cdf of N(0,1), h is a threshold, and \mu and \sigma are the mean and (noisy) - standard deviation of the Gaussian process. - - Note that when using a log discrepancy, h should become log(h). - - References - ---------- - Gutmann M U, Corander J (2016). Bayesian Optimization for Likelihood-Free Inference - of Simulator-Based Statistical Models. JMLR 17(125):1−47, 2016. - http://jmlr.org/papers/v17/15-017.html - - Parameters - ---------- - model : object - Instance of the surrogate model, e.g. elfi.bo.gpy_regression.GPyRegression. - threshold : float, optional - The threshold value used in the calculation of the posterior, see the BOLFI paper for details. - By default, the minimum value of discrepancy estimate mean is used. - priors : list of elfi.Priors, optional - By default uniform distribution within model bounds. - max_opt_iters : int, optional - Maximum number of iterations performed in internal optimization. - """ - - def __init__(self, model, threshold=None, priors=None, max_opt_iters=10000): - super(BolfiPosterior, self).__init__() - self.threshold = threshold - self.model = model - if self.threshold is None: - minloc, minval = stochastic_optimization(self.model.predict_mean, self.model.bounds, max_opt_iters) - self.threshold = minval - logger.info("Using minimum value of discrepancy estimate mean (%.4f) as threshold" % (self.threshold)) - self.priors = priors or [None] * model.input_dim - self.max_opt_iters = max_opt_iters - - @property - def ML(self): - """ - Maximum likelihood (ML) approximation. - - Returns - ------- - tuple - Maximum likelihood parameter values and the corresponding value of neg_unnormalized_loglikelihood. - """ - x, lh_x = stochastic_optimization(self._neg_unnormalized_loglikelihood, - self.model.bounds, self.max_opt_iters) - return x, lh_x - - @property - def MAP(self): - """ - Maximum a posteriori (MAP) approximation. - - Returns - ------- - tuple - Maximum a posteriori parameter values and the corresponding value of neg_unnormalized_logposterior. - """ - x, post_x = stochastic_optimization(self._neg_unnormalized_logposterior, - self.model.bounds, self.max_opt_iters) - return x, post_x - - def logpdf(self, x): - """ - Returns the unnormalized log-posterior pdf at x. - - Parameters - ---------- - x : np.array - - Returns - ------- - float - """ - if not self._within_bounds(x): - return -np.inf - return self._unnormalized_loglikelihood(x) + self._logprior_density(x) - - def pdf(self, x): - """ - Returns the unnormalized posterior pdf at x. - - Parameters - ---------- - x : np.array - - Returns - ------- - float - """ - return np.exp(self.logpdf(x)) - - def grad_logpdf(self, x): - """ - Returns the gradient of the unnormalized log-posterior pdf at x. - - Parameters - ---------- - x : np.array - - Returns - ------- - np.array - """ - grad = self._grad_unnormalized_loglikelihood(x) + self._grad_logprior_density(x) - return grad[0] - - def __getitem__(self, idx): - return tuple([[v]*len(idx) for v in self.MAP]) - - def _unnormalized_loglikelihood(self, x): - mean, var = self.model.predict(x) - if mean is None or var is None: - raise ValueError("Unable to evaluate model at %s" % (x)) - return sp.stats.norm.logcdf(self.threshold, mean, np.sqrt(var)) - - def _grad_unnormalized_loglikelihood(self, x): - mean, var = self.model.predict(x) - if mean is None or var is None: - raise ValueError("Unable to evaluate model at %s" % (x)) - std = np.sqrt(var) - - grad_mean, grad_var = self.model.predictive_gradients(x) - grad_mean = grad_mean[:, :, 0] # assume 1D output - - factor = -grad_mean * std - (self.threshold - mean) * 0.5 * grad_var / std - factor = factor / var - term = (self.threshold - mean) / std - pdf = sp.stats.norm.pdf(term) - cdf = sp.stats.norm.cdf(term) - - return factor * pdf / cdf - - def _unnormalized_likelihood(self, x): - return np.exp(self._unnormalized_loglikelihood(x)) - - def _neg_unnormalized_loglikelihood(self, x): - return -1 * self._unnormalized_loglikelihood(x) - - def _neg_unnormalized_logposterior(self, x): - return -1 * self.logpdf(x) - - def _logprior_density(self, x): - logprior_density = 0.0 - for xv, prior in zip(x, self.priors): - if prior is not None: - logprior_density += prior.logpdf(xv) - return logprior_density - - def _within_bounds(self, x): - x = x.reshape((-1, self.model.input_dim)) - for ii in range(self.model.input_dim): - if np.any(x[:, ii] < self.model.bounds[ii][0]) or np.any(x[:, ii] > self.model.bounds[ii][1]): - return False - return True - - def _grad_logprior_density(self, x): - grad_logprior_density = np.zeros(x.shape) - for ii, prior in enumerate(self.priors): - if prior is not None: - grad_logprior_density[ii] = prior.grad_logpdf(x[ii]) - return grad_logprior_density - - def _prior_density(self, x): - return np.exp(self._logprior_density(x)) - - def _neg_logprior_density(self, x): - return -1 * self._logprior_density(x) - - def plot(self): - if len(self.model.bounds) == 1: - mn = self.model.bounds[0][0] - mx = self.model.bounds[0][1] - dx = (mx - mn) / 200.0 - x = np.arange(mn, mx, dx) - pd = np.zeros(len(x)) - for i in range(len(x)): - pd[i] = self.pdf([x[i]]) - plt.figure() - plt.plot(x, pd) - plt.xlim(mn, mx) - plt.ylim(0.0, max(pd)*1.05) - plt.show() - - elif len(self.model.bounds) == 2: - x, y = np.meshgrid(np.linspace(*self.model.bounds[0]), np.linspace(*self.model.bounds[1])) - z = (np.vectorize(lambda a,b: self.pdf(np.array([a, b]))))(x, y) - plt.contour(x, y, z) - plt.show() - - else: - raise NotImplementedError("Currently not supported for dim > 2") diff --git a/elfi/results/result.py b/elfi/methods/results.py similarity index 57% rename from elfi/results/result.py rename to elfi/methods/results.py index e0636bab..3d680e1e 100644 --- a/elfi/results/result.py +++ b/elfi/methods/results.py @@ -1,10 +1,16 @@ -import numpy as np -from collections import OrderedDict -import sys import io -import matplotlib.pyplot as plt +import logging +import sys +from collections import OrderedDict + +import numpy as np +import scipy as sp +from matplotlib import pyplot as plt import elfi.visualization.visualization as vis +from elfi.methods.bo.utils import stochastic_optimization + +logger = logging.getLogger(__name__) """ @@ -139,7 +145,7 @@ def plot_pairs(self, selector=None, bins=20, axes=None, **kwargs): The y-axis of marginal histograms are scaled. - Parameters + Parameters ---------- selector : iterable of ints or strings, optional Indices or keys to use from samples. Default to all. @@ -265,3 +271,205 @@ def __init__(self, method_name, chains, parameter_names, warmup, **kwargs): def plot_traces(self, selector=None, axes=None, **kwargs): return vis.plot_traces(self, selector, axes, **kwargs) + + +class BolfiPosterior(object): + """ + Container for the approximate posterior in the BOLFI framework, where the likelihood + is defined as + + L \propto F((h - \mu) / \sigma) + + where F is the cdf of N(0,1), h is a threshold, and \mu and \sigma are the mean and (noisy) + standard deviation of the Gaussian process. + + Note that when using a log discrepancy, h should become log(h). + + References + ---------- + Gutmann M U, Corander J (2016). Bayesian Optimization for Likelihood-Free Inference + of Simulator-Based Statistical Models. JMLR 17(125):1−47, 2016. + http://jmlr.org/papers/v17/15-017.html + + Parameters + ---------- + model : object + Instance of the surrogate model, e.g. elfi.bo.gpy_regression.GPyRegression. + threshold : float, optional + The threshold value used in the calculation of the posterior, see the BOLFI paper for details. + By default, the minimum value of discrepancy estimate mean is used. + priors : list of elfi.Priors, optional + By default uniform distribution within model bounds. + max_opt_iters : int, optional + Maximum number of iterations performed in internal optimization. + """ + + def __init__(self, model, threshold=None, priors=None, max_opt_iters=10000): + super(BolfiPosterior, self).__init__() + self.threshold = threshold + self.model = model + if self.threshold is None: + minloc, minval = stochastic_optimization(self.model.predict_mean, self.model.bounds, max_opt_iters) + self.threshold = minval + logger.info("Using minimum value of discrepancy estimate mean (%.4f) as threshold" % (self.threshold)) + self.priors = priors or [None] * model.input_dim + self.max_opt_iters = max_opt_iters + + @property + def ML(self): + """ + Maximum likelihood (ML) approximation. + + Returns + ------- + tuple + Maximum likelihood parameter values and the corresponding value of neg_unnormalized_loglikelihood. + """ + x, lh_x = stochastic_optimization(self._neg_unnormalized_loglikelihood, + self.model.bounds, self.max_opt_iters) + return x, lh_x + + @property + def MAP(self): + """ + Maximum a posteriori (MAP) approximation. + + Returns + ------- + tuple + Maximum a posteriori parameter values and the corresponding value of neg_unnormalized_logposterior. + """ + x, post_x = stochastic_optimization(self._neg_unnormalized_logposterior, + self.model.bounds, self.max_opt_iters) + return x, post_x + + def logpdf(self, x): + """ + Returns the unnormalized log-posterior pdf at x. + + Parameters + ---------- + x : np.array + + Returns + ------- + float + """ + if not self._within_bounds(x): + return -np.inf + return self._unnormalized_loglikelihood(x) + self._logprior_density(x) + + def pdf(self, x): + """ + Returns the unnormalized posterior pdf at x. + + Parameters + ---------- + x : np.array + + Returns + ------- + float + """ + return np.exp(self.logpdf(x)) + + def grad_logpdf(self, x): + """ + Returns the gradient of the unnormalized log-posterior pdf at x. + + Parameters + ---------- + x : np.array + + Returns + ------- + np.array + """ + grad = self._grad_unnormalized_loglikelihood(x) + self._grad_logprior_density(x) + return grad[0] + + def __getitem__(self, idx): + return tuple([[v]*len(idx) for v in self.MAP]) + + def _unnormalized_loglikelihood(self, x): + mean, var = self.model.predict(x) + if mean is None or var is None: + raise ValueError("Unable to evaluate model at %s" % (x)) + return sp.stats.norm.logcdf(self.threshold, mean, np.sqrt(var)) + + def _grad_unnormalized_loglikelihood(self, x): + mean, var = self.model.predict(x) + if mean is None or var is None: + raise ValueError("Unable to evaluate model at %s" % (x)) + std = np.sqrt(var) + + grad_mean, grad_var = self.model.predictive_gradients(x) + grad_mean = grad_mean[:, :, 0] # assume 1D output + + factor = -grad_mean * std - (self.threshold - mean) * 0.5 * grad_var / std + factor = factor / var + term = (self.threshold - mean) / std + pdf = sp.stats.norm.pdf(term) + cdf = sp.stats.norm.cdf(term) + + return factor * pdf / cdf + + def _unnormalized_likelihood(self, x): + return np.exp(self._unnormalized_loglikelihood(x)) + + def _neg_unnormalized_loglikelihood(self, x): + return -1 * self._unnormalized_loglikelihood(x) + + def _neg_unnormalized_logposterior(self, x): + return -1 * self.logpdf(x) + + def _logprior_density(self, x): + logprior_density = 0.0 + for xv, prior in zip(x, self.priors): + if prior is not None: + logprior_density += prior.logpdf(xv) + return logprior_density + + def _within_bounds(self, x): + x = x.reshape((-1, self.model.input_dim)) + for ii in range(self.model.input_dim): + if np.any(x[:, ii] < self.model.bounds[ii][0]) or np.any(x[:, ii] > self.model.bounds[ii][1]): + return False + return True + + def _grad_logprior_density(self, x): + grad_logprior_density = np.zeros(x.shape) + for ii, prior in enumerate(self.priors): + if prior is not None: + grad_logprior_density[ii] = prior.grad_logpdf(x[ii]) + return grad_logprior_density + + def _prior_density(self, x): + return np.exp(self._logprior_density(x)) + + def _neg_logprior_density(self, x): + return -1 * self._logprior_density(x) + + def plot(self): + if len(self.model.bounds) == 1: + mn = self.model.bounds[0][0] + mx = self.model.bounds[0][1] + dx = (mx - mn) / 200.0 + x = np.arange(mn, mx, dx) + pd = np.zeros(len(x)) + for i in range(len(x)): + pd[i] = self.pdf([x[i]]) + plt.figure() + plt.plot(x, pd) + plt.xlim(mn, mx) + plt.ylim(0.0, max(pd)*1.05) + plt.show() + + elif len(self.model.bounds) == 2: + x, y = np.meshgrid(np.linspace(*self.model.bounds[0]), np.linspace(*self.model.bounds[1])) + z = (np.vectorize(lambda a,b: self.pdf(np.array([a, b]))))(x, y) + plt.contour(x, y, z) + plt.show() + + else: + raise NotImplementedError("Currently not supported for dim > 2") \ No newline at end of file diff --git a/elfi/model/elfi_model.py b/elfi/model/elfi_model.py index 640aebdf..87399a37 100644 --- a/elfi/model/elfi_model.py +++ b/elfi/model/elfi_model.py @@ -13,9 +13,10 @@ from elfi.store import OutputPool from elfi.utils import scipy_from_str, observed_name -__all__ = ['ElfiModel', 'ComputationContext', 'Constant', 'Operation', 'Prior', - 'Simulator', 'Summary', 'Discrepancy', 'Distance', 'get_current_model', - 'set_current_model'] +__all__ = ['ElfiModel', 'ComputationContext', 'NodeReference', 'RandomVariable', + 'Constant', 'Operation', + 'Prior', 'Simulator', 'Summary', 'Discrepancy', 'Distance', + 'get_current_model', 'set_current_model'] """ This module contains the classes for creating generative models in ELFI. The class that contains the whole representation of this generative model is named `ElfiModel`. @@ -192,9 +193,11 @@ def copy(self): class ElfiModel(GraphicalModel): + """A generative model for LFI + """ def __init__(self, name=None, source_net=None, computation_context=None, set_current=True): - """Create a new ElfiModel instance + """ Parameters ---------- @@ -215,20 +218,25 @@ def __init__(self, name=None, source_net=None, computation_context=None, @property def name(self): + """Name of the model""" return self.source_net.graph['name'] @name.setter def name(self, name): + """Sets the name of the model""" self.source_net.graph['name'] = name def generate(self, batch_size=1, outputs=None, with_values=None): - """Generates a batch using the global seed. Useful for testing. + """Generates a batch of outputs using the global seed. + + This method is useful for testing that the generative model works. Parameters ---------- batch_size : int outputs : list with_values : dict + You can specify values for nodes to use when generating data """ @@ -254,6 +262,7 @@ def generate(self, batch_size=1, outputs=None, with_values=None): return client.compute(loaded_net) def get_reference(self, name): + """Returns a new node reference object for a node in the model.""" cls = self.get_node(name)['_class'] return cls.reference(name, self) @@ -265,14 +274,14 @@ def update_node(self, node, updating_node): super(ElfiModel, self).update_node(node, updating_node) - @property def observed(self): + """The observed data for the nodes in a dictionary.""" return self.computation_context.observed @property def parameters(self): - """Returns a list of parameters in an alphabetical order.""" + """A list of model parameters in an alphabetical order.""" return sorted([n for n in self.nodes if '_parameter' in self.get_state(n)]) @parameters.setter @@ -327,9 +336,10 @@ def uses_meta(self, val): class NodeReference(InstructionsMapper): - """This is a base class for reference objects to nodes that a user of ELFI will - typically use, e.g. `elfi.Prior` or `elfi.Simulator` to create state dictionaries for - nodes. + """A base class for node objects in the model. + + A user of ELFI will typically use, e.g. `elfi.Prior` or `elfi.Simulator` to create + state dictionaries for nodes. Each node has a state dictionary that describes how the node ultimately produces its output (see module documentation for more details). The state is stored in the @@ -406,7 +416,7 @@ def _determine_model(self, model, parents): @property def parents(self): - """ + """Get all the positional parent nodes (inputs) of this node Returns ------- @@ -417,7 +427,7 @@ def parents(self): @classmethod def reference(cls, name, model): - """Creates a reference for an existing node + """Constructor for creating a reference for an existing node in the model Parameters ---------- @@ -434,7 +444,9 @@ def reference(cls, name, model): return instance def become(self, other_node): - """Replaces self state with other_node's state and updates the class + """Make this node become the `other_node`. + + The children of this node will be preserved. Parameters ---------- @@ -468,7 +480,9 @@ def _init_reference(self, name, model): self.model = model def generate(self, batch_size=1, with_values=None): - """Generates a batch. Useful for testing. + """Generates output from this node. + + Useful for testing. Parameters ---------- @@ -525,6 +539,7 @@ def _new_name(self, basename='', model=None): @property def state(self): + """State dictionary of the node""" if self.model is None: raise ValueError('{} {} is not initialized'.format(self.__class__.__name__, self.name)) @@ -548,6 +563,10 @@ def __str__(self): class StochasticMixin(NodeReference): + """Makes a node stochastic + + Operations of stochastic nodes will receive a `random_state` keyword argument. + """ def __init__(self, *parents, state, **kwargs): # Flag that this node is stochastic state['_stochastic'] = True @@ -555,7 +574,11 @@ def __init__(self, *parents, state, **kwargs): class ObservableMixin(NodeReference): - """ + """Makes a node observable + + Observable nodes accept observed keyword argument. In addition the compiled + model will contain a sister node that contains the observed value or will compute the + observed value from the observed values of it's parents. """ def __init__(self, *parents, state, observed=None, **kwargs): @@ -578,13 +601,14 @@ def observed(self): class Constant(NodeReference): + """A node holding a constant value.""" def __init__(self, value, **kwargs): state = dict(_output=value) super(Constant, self).__init__(state=state, **kwargs) class Operation(NodeReference): - """A generic operation node. + """A generic deterministic operation node. """ def __init__(self, fn, *parents, **kwargs): state = dict(_operation=fn) @@ -592,6 +616,8 @@ def __init__(self, fn, *parents, **kwargs): class RandomVariable(StochasticMixin, NodeReference): + """A node that draws values from a random distribution.""" + def __init__(self, distribution, *params, size=None, **kwargs): """ @@ -600,7 +626,7 @@ def __init__(self, distribution, *params, size=None, **kwargs): distribution : str or scipy-like distribution object params : params of the distribution size : int, tuple or None, optional - size of a single random draw. None (default) means a scalar. + Output size of a single random draw. """ @@ -632,6 +658,7 @@ def compile_operation(state): @property def distribution(self): + """Returns the distribution object.""" distribution = self['distribution'] if isinstance(distribution, str): distribution = scipy_from_str(distribution) @@ -639,6 +666,7 @@ def distribution(self): @property def size(self): + """Returns the size of the output from the distribution.""" return self['size'] def __repr__(self): @@ -657,19 +685,77 @@ def __repr__(self): class Prior(RandomVariable): - def __init__(self, distribution="uniform", *params, **kwargs): - super(Prior, self).__init__(distribution, *params, **kwargs) + """A parameter node of a generative model.""" + def __init__(self, distribution, *params, size=None, **kwargs): + """ + + Parameters + ---------- + distribution : str, object + Any distribution from `scipy.stats`, either as a string or an object. Objects + must implement at least an `rvs` method with signature + `rvs(*parameters, size, random_state)`. Can also be a custom distribution + object that implements at least an `rvs` method. Many of the algorithms also + require the `pdf` and `logpdf` methods to be available. + size : int, tuple or None, optional + Output size of a single random draw. + params + Parameters of the prior distribution + kwargs + + Notes + ----- + The parameters of the `scipy` distributions (typically `loc` and `scale`) must be + given as positional arguments. + + Many algorithms (e.g. SMC) also require a `pdf` method for the distribution. In + general the definition of the distribution is a subset of + `scipy.stats.rv_continuous`. + + Scipy distributions: https://docs.scipy.org/doc/scipy-0.19.0/reference/stats.html + + """ + super(Prior, self).__init__(distribution, *params, size=size, **kwargs) self['_parameter'] = True class Simulator(StochasticMixin, ObservableMixin, NodeReference): + """A simulator node of a generative model. + + Simulator nodes are stochastic and may have observed data in the model. + """ def __init__(self, fn, *params, **kwargs): + """ + + Parameters + ---------- + fn : callable + Simulator function with a signature `sim(*params, batch_size, random_state)` + params + Input parameters for the simulator. + kwargs + """ state = dict(_operation=fn, _uses_batch_size=True) super(Simulator, self).__init__(*params, state=state, **kwargs) class Summary(ObservableMixin, NodeReference): + """A summary node of a generative model. + + Summary nodes are deterministic operations associated with the observed data. if their + parents hold observed data it will be automatically transformed. + """ def __init__(self, fn, *parents, **kwargs): + """ + + Parameters + ---------- + fn : callable + Summary function with a signature `summary(*parents)` + parents + Input data for the summary function. + kwargs + """ if not parents: raise ValueError('This node requires that at least one parent is specified.') state = dict(_operation=fn) @@ -678,25 +764,24 @@ def __init__(self, fn, *parents, **kwargs): class Discrepancy(NodeReference): def __init__(self, discrepancy, *parents, **kwargs): - """Discrepancy node. + """A discrepancy node of a generative model. + Parameters ---------- discrepancy : callable Signature of the discrepancy function is of the form: - `discrepancy(summary_1, summary_2, ..., observed)`, where: - summary_i : array-like - containing n simulated values of summary_i in its elements, where n is the - batch size. + `discrepancy(summary_1, summary_2, ..., observed)`, where summaries are + arrays containing `batch_size` simulated values. - The callable should return a vector of n discrepancies between the simulated + The callable should return a vector of discrepancies between the simulated summaries and the observed summaries. observed : tuple tuple (observed_summary_1, observed_summary_2, ...) See Also -------- - See the `elfi.Distance` for creating common distance discrepancies. + elfi.Distance : creating common distance discrepancies. """ if not parents: @@ -708,26 +793,22 @@ def __init__(self, discrepancy, *parents, **kwargs): # TODO: add weights class Distance(Discrepancy): def __init__(self, distance, *summaries, p=None, w=None, V=None, VI=None, **kwargs): - """Distance node. + """A distance node of a generative model. - The summaries will be first stacked to a single 2D array with the simulated - summaries in the rows for every simulation and the distance is taken row - wise against the corresponding observed summary vector. + This class contains many common distance implementations through scipy. Parameters ---------- - distance : callable, str - Signature of the callable distance function is of the form: `distance(X, Y)`, - where - X : np.ndarray - n x m array containing n simulated values (summaries) in rows - Y : np.ndarray - 1 x m array that containing the observed values (summaries) in the row - If string, it must be a valid metric for `scipy.spatial.distance.cdist`. - - The callable should return a vector of distances between the simulated - summaries and the observed summaries. + distance : str, callable + If string it must be a valid metric from `scipy.spatial.distance.cdist`. + + Is a callable, the signature must be `distance(X, Y)`, where X is a n x m + array containing n simulated values (summaries) in rows and Y is a 1 x m array + that contains the observed values (summaries). The callable should return + a vector of distances between the simulated summaries and the observed + summaries. summaries + summary nodes of the model p : double, optional The p-norm to apply Only for distance Minkowski (`'minkowski'`), weighted and unweighted. Default: 2. @@ -747,11 +828,16 @@ def __init__(self, distance, *summaries, p=None, w=None, V=None, VI=None, **kwar Notes ----- - Your summaries need to be scalars or vectors for this method to work. + Your summaries need to be scalars or vectors for this method to work. The + summaries will be first stacked to a single 2D array with the simulated + summaries in the rows for every simulation and the distance is taken row + wise against the corresponding observed summary vector. + + Scipy distances: https://docs.scipy.org/doc/scipy/reference/generated/generated/scipy.spatial.distance.cdist.html See Also -------- - https://docs.scipy.org/doc/scipy/reference/generated/generated/scipy.spatial.distance.cdist.html + elfi.Discrepancy : A general discrepancy node """ if not summaries: diff --git a/elfi/results/__init__.py b/elfi/results/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/requirements-dev.txt b/requirements-dev.txt index 3659f8ae..5a6d9a07 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -13,4 +13,5 @@ flake8-isort>=2.0.1 # Documentation Sphinx>=1.4.8 -watchdog>=0.8.3 +numpydoc>=0.6 + diff --git a/setup.py b/setup.py index cc2cda6a..12ca54bc 100644 --- a/setup.py +++ b/setup.py @@ -3,9 +3,6 @@ from io import open -with open('docs/readme.rst', 'r', encoding='utf-8') as f: - long_description = f.read() - packages = ['elfi'] + ['elfi.' + p for p in find_packages('elfi')] # include C++ examples @@ -36,7 +33,7 @@ extras_require=optionals, description='Modular ABC inference framework for python', - long_description=long_description, + long_description=(open('docs/description.rst').read()), license='BSD', diff --git a/tests/functional/test_inference.py b/tests/functional/test_inference.py index f44d14c7..15d0056f 100644 --- a/tests/functional/test_inference.py +++ b/tests/functional/test_inference.py @@ -111,7 +111,7 @@ def test_BOLFI(): acq_x = bolfi.target_model._gp.X # check_inference_with_informative_data(res, 1, true_params, error_bound=.2) - assert np.abs(res['samples']['t1'] - true_params['t1']) < 0.15 + assert np.abs(res['samples']['t1'] - true_params['t1']) < 0.2 assert np.abs(res['samples']['t2'] - true_params['t2']) < 0.2 # Test that you can continue the inference where we left off diff --git a/tests/unit/test_elfi_model.py b/tests/unit/test_elfi_model.py index 7b5162f9..060b3301 100644 --- a/tests/unit/test_elfi_model.py +++ b/tests/unit/test_elfi_model.py @@ -71,7 +71,7 @@ def test_name_determination(self): assert node.name != 'node' # Works with sub classes - pri = em.Prior() + pri = em.Prior('uniform') assert pri.name == 'pri' # Assigns random names when the name isn't self explanatory diff --git a/tests/unit/test_mcmc.py b/tests/unit/test_mcmc.py index 86a51b50..014171d9 100644 --- a/tests/unit/test_mcmc.py +++ b/tests/unit/test_mcmc.py @@ -1,9 +1,6 @@ -from functools import partial -import pytest import numpy as np -from elfi import mcmc - +from elfi.methods import mcmc # construct a covariance matrix and calculate the precision matrix n = 5 diff --git a/tests/unit/test_results.py b/tests/unit/test_results.py index 9e322a06..86b8eac0 100644 --- a/tests/unit/test_results.py +++ b/tests/unit/test_results.py @@ -1,9 +1,6 @@ import pytest -import logging -import numpy as np - -from elfi.results.result import * +from elfi.methods.results import * def test_Result(): diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py index a6544406..0c1f92d0 100644 --- a/tests/unit/test_utils.py +++ b/tests/unit/test_utils.py @@ -1,10 +1,8 @@ import numpy as np import scipy.stats as ss -from scipy.integrate import dblquad -import elfi +from elfi.methods.bo.utils import stochastic_optimization from elfi.methods.utils import weighted_var, GMDistribution, normalize_weights -from elfi.bo.utils import stochastic_optimization def test_stochastic_optimization(): From 2551a0d71a0273f5fd26dc3fbd0443c5464b15ec Mon Sep 17 00:00:00 2001 From: Jarno Lintusaari Date: Tue, 6 Jun 2017 09:51:49 +0300 Subject: [PATCH 05/38] API documentation continued (#168) --- .gitignore | 8 +- docs/api.rst | 90 ++++++++++++++++++++- docs/conf.py | 2 +- elfi/__init__.py | 3 +- elfi/client.py | 2 + elfi/model/elfi_model.py | 5 ++ elfi/model/tools.py | 96 +++++++++++------------ elfi/store.py | 117 +++++++++++++++++++++------- elfi/visualization/visualization.py | 14 +++- 9 files changed, 247 insertions(+), 90 deletions(-) diff --git a/.gitignore b/.gitignore index 23b5eaa7..8bba4641 100644 --- a/.gitignore +++ b/.gitignore @@ -29,10 +29,10 @@ var/ *.egg # Images -.png -.svg -.jpg -.jpeg +*.png +*.svg +*.jpg +*.jpeg # PyInstaller # Usually these files are written by a python script from a template diff --git a/docs/api.rst b/docs/api.rst index 491fdb80..62d5f9b0 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -25,9 +25,22 @@ Below is the API for creating generative models. elfi.Discrepancy elfi.Distance +**Other** + +.. currentmodule:: elfi.model.elfi_model + +.. autosummary:: + elfi.get_current_model + elfi.set_current_model + +.. currentmodule:: elfi.visualization.visualization + +.. autosummary:: + elfi.draw Inference API ------------- + Below is a list of inference methods included in ELFI. .. autosummary:: @@ -45,6 +58,34 @@ Below is a list of inference methods included in ELFI. ResultSMC ResultBOLFI +Other +----- + +**Data pools** + +.. autosummary:: + elfi.OutputPool + elfi.ArrayPool + + +**Module functions** + +.. currentmodule:: elfi + +.. autosummary:: + elfi.get_client + elfi.set_client + + +**Tools** + +.. currentmodule:: elfi.model.tools + +.. autosummary:: + elfi.tools.vectorize + elfi.tools.external_operation + + Class documentations -------------------- @@ -87,6 +128,19 @@ Modelling API classes :members: :inherited-members: + +**Other** + +.. currentmodule:: elfi.model.elfi_model + +.. automethod:: elfi.get_current_model + +.. automethod:: elfi.set_current_model + +.. currentmodule:: elfi.visualization.visualization + +.. automethod:: elfi.visualization.visualization.nx_draw + .. This would show undocumented members :undoc-members: @@ -109,6 +163,8 @@ Inference API classes :members: :inherited-members: +.. currentmodule:: elfi.methods.results + .. autoclass:: Result :members: :inherited-members: @@ -119,4 +175,36 @@ Inference API classes .. autoclass:: ResultBOLFI :members: - :inherited-members: \ No newline at end of file + :inherited-members: + + +Other +..... + +**Data pools** + +.. autoclass:: elfi.OutputPool + :members: + :inherited-members: + +.. autoclass:: elfi.ArrayPool + :members: + :inherited-members: + + +**Module functions** + +.. currentmodule:: elfi + +.. automethod:: elfi.get_client + +.. automethod:: elfi.set_client + + +**Tools** + +.. currentmodule:: elfi.model.tools + +.. automethod:: elfi.tools.vectorize + +.. automethod:: elfi.tools.external_operation diff --git a/docs/conf.py b/docs/conf.py index 9a5a5814..c555444a 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -160,7 +160,7 @@ def setup(app): # If true, the current module name will be prepended to all description # unit titles (such as .. function::). # -# add_module_names = False +add_module_names = True # If true, sectionauthor and moduleauthor directives will be shown in the # output. They are ignored by default. diff --git a/elfi/__init__.py b/elfi/__init__.py index ea119606..addd5b6a 100644 --- a/elfi/__init__.py +++ b/elfi/__init__.py @@ -1,13 +1,14 @@ # -*- coding: utf-8 -*- import elfi.clients.native + import elfi.methods.mcmc import elfi.model.tools as tools from elfi.client import get_client, set_client from elfi.methods.methods import * from elfi.model.elfi_model import * from elfi.model.extensions import ScipyLikeDistribution as Distribution -from elfi.store import OutputPool +from elfi.store import OutputPool, ArrayPool from elfi.visualization.visualization import nx_draw as draw __author__ = 'ELFI authors' diff --git a/elfi/client.py b/elfi/client.py index db36b46d..30f858af 100644 --- a/elfi/client.py +++ b/elfi/client.py @@ -18,6 +18,7 @@ def get_client(): + """Get the current ELFI client instance.""" global _client if _client is None: if _default_class is None: @@ -27,6 +28,7 @@ def get_client(): def set_client(client=None): + """Set the current ELFI client instance.""" global _client _client = client diff --git a/elfi/model/elfi_model.py b/elfi/model/elfi_model.py index 87399a37..1c2dd53e 100644 --- a/elfi/model/elfi_model.py +++ b/elfi/model/elfi_model.py @@ -114,6 +114,10 @@ def get_current_model(): + """Return the current default `elfi.ElfiModel` instance. + + New nodes will be added to this model by default. + """ global _current_model if _current_model is None: _current_model = ElfiModel() @@ -121,6 +125,7 @@ def get_current_model(): def set_current_model(model=None): + """Set the current default `elfi.ElfiModel` instance.""" global _current_model if model is None: model = ElfiModel() diff --git a/elfi/model/tools.py b/elfi/model/tools.py index 1f8376b0..1bfff88f 100644 --- a/elfi/model/tools.py +++ b/elfi/model/tools.py @@ -17,31 +17,16 @@ def run_vectorized(operation, *inputs, constants=None, batch_size=None, **kwargs Parameters ---------- operation : callable - operation that will be run `batch_size` times. + Operation that will be run `batch_size` times. inputs - inputs from the parent nodes from ElfiModel + Inputs from the parent nodes. constants : tuple or int, optional - a mask for constants in inputs, e.g. (0, 2) would indicate that the first and + A mask for constants in inputs, e.g. (0, 2) would indicate that the first and third input are constants. The constants will be passed as they are to each operation call. batch_size : int, optional kwargs - Notes - ----- - This is an experimental feature. - - This is a convenience method and uses a for loop for vectorization. For best - performance, one should aim to implement vectorized operations (by using e.g. numpy - functions that are mostly vectorized) if at all possible. - - If the output from the operation is not a numpy array or if the shape of the output - in different runs differs, the `dtype` of the returned numpy array will be `object`. - - If the node has a parameter `batch_index`, then also `run_index` will be added - to the passed parameters that tells the current index of this run within the batch, - i.e. 0 <= `run_index` < `batch_size`. - Returns ------- operation_output @@ -101,6 +86,8 @@ def run_vectorized(operation, *inputs, constants=None, batch_size=None, **kwargs def vectorize(operation=None, constants=None): """Vectorizes an operation. + Helper for cases when you have an operation that does not support vector arguments. + Parameters ---------- operation : callable, optional @@ -112,44 +99,55 @@ def vectorize(operation=None, constants=None): Notes ----- - If you need to pickle the vectorized simulator (for parallel execution) and don't have - `dill` or a similar package available, you must use the direct form. See the first - example below. + The decorator form does not always produce a pickleable object. The parallel execution + requires the simulator to be pickleable. Therefore it is not recommended to use + the decorator syntax unless you are using `dill` or a similar package. + + This is a convenience method and uses a for loop for vectorization. For best + performance, one should aim to implement vectorized operations (by using e.g. numpy + functions that are mostly vectorized) if at all possible. + + If the output from the operation is not a numpy array or if the shape of the output + in different runs differs, the `dtype` of the returned numpy array will be `object`. + + If the node has a parameter `batch_index`, then also `run_index` will be added + to the passed parameters that tells the current index of this run within the batch, + i.e. 0 <= `run_index` < `batch_size`. Examples -------- - ``` + :: - # Call directly - vectorized_simulator = elfi.tools.vectorize(simulator) + # Call directly (recommended) + vectorized_simulator = elfi.tools.vectorize(simulator) - # As a decorator without arguments - @elfi.tools.vectorize - def simulator(a, b, random_state=None): - # Simulator code - pass + # As a decorator without arguments + @elfi.tools.vectorize + def simulator(a, b, random_state=None): + # Simulator code + pass - @elfi.tools.vectorize(constants=1) - def simulator(a, constant, random_state=None): - # Simulator code - pass + @elfi.tools.vectorize(constants=1) + def simulator(a, constant, random_state=None): + # Simulator code + pass - @elfi.tools.vectorize(1) - def simulator(a, constant, random_state=None): - # Simulator code - pass + @elfi.tools.vectorize(1) + def simulator(a, constant, random_state=None): + # Simulator code + pass - @elfi.tools.vectorize(constants=(0,2)) - def simulator(constant0, b, constant2, random_state=None): - # Simulator code - pass - ``` + @elfi.tools.vectorize(constants=(0,2)) + def simulator(constant0, b, constant2, random_state=None): + # Simulator code + pass """ - # Test if used as a decorator without arguments or as a function call + # Cases direct call or a decorator without arguments if callable(operation): return partial(run_vectorized, operation, constants=constants) - # Cases where constants is given as a positional argument + + # Decorator with parameters elif isinstance(operation, int): constants = tuple([operation]) elif isinstance(operation, (tuple, list)): @@ -226,7 +224,7 @@ def run_external(command, *inputs, process_result=None, prepare_inputs=None, def external_operation(command, process_result=None, prepare_inputs=None, sep=' ', stdout=True, subprocess_kwargs=None): - """Wrap an external command to an ELFI compatible Python callable. + """Wrap an external command as a Python callable (function). The external command can be e.g. a shell script, or an executable file. @@ -241,11 +239,9 @@ def external_operation(command, process_result=None, prepare_inputs=None, sep=' Callable result handler with a signature `output = callable(result, *inputs, **kwinputs)`. Here the `result` is either the stdout or `subprocess.CompletedProcess` depending on the stdout flag below. The - inputs and kwinputs will come from elfi. - - Default handler converts the stdout to numpy array with - `array = np.fromstring(stdout, sep=sep). If `process_result` is `np.dtype` or a - string, then the stdout data is casted to that type with + inputs and kwinputs will come from ELFI. The default handler converts the stdout + to numpy array with `array = np.fromstring(stdout, sep=sep)`. If `process_result` + is `np.dtype` or a string, then the stdout data is casted to that type with `stdout = np.fromstring(stdout, sep=sep, dtype=process_result)`. prepare_inputs : callable, optional Callable with a signature `inputs, kwinputs = callable(*inputs, **kwinputs)`. The diff --git a/elfi/store.py b/elfi/store.py index 3c243c99..ca2decf0 100644 --- a/elfi/store.py +++ b/elfi/store.py @@ -7,11 +7,19 @@ class OutputPool: - """Allows storing outputs to different stores.""" + """Store node outputs to dictionary-like stores. + The default store is a Python dictionary. + + Notes + ----- + See the `elfi.store.BatchStore` interface if you wish to implement your own ELFI + compatible store. + + """ def __init__(self, outputs=None): - """Build a OutputPool object for storing the generated values of nodes. - + """ + Depending on the algorithm, some of these values may be reused after making some changes to `ElfiModel` thus speeding up the inference significantly. For instance, if all the simulations are stored in Rejection @@ -76,7 +84,7 @@ def __getitem__(self, node): return self.output_stores[node] def add_batch(self, batch, batch_index): - """Adds a batch to stores.""" + """Adds the outputs from the batch to their stores.""" for node, store in self.output_stores.items(): if node not in batch: continue @@ -130,7 +138,7 @@ def __contains__(self, node): return node in self.output_stores def clear(self): - """Removes all data from the pool stores""" + """Removes all data from the stores""" for store in self.output_stores.values(): store.clear() @@ -139,20 +147,39 @@ def outputs(self): return self.output_stores.keys() +# TODO: Make it easier to load ArrayPool with just a name. +# we could store the context to the pool folder, and drop the use of a seed in the +# folder name +# TODO: Extend to general arrays. +# This probably requires using a mask class ArrayPool(OutputPool): + """Store node outputs to arrays. + + The default medium for output data is a numpy binary `.npy` file, that stores array + data. Separate files will be created for different nodes. + + Notes + ----- + + Internally the `elfi.ArrayPool` will create an `elfi.store.BatchArrayStore' object + wrapping a `NpyPersistedArray` for each output. The `elfi.store.NpyPersistedArray` + object is responsible for managing the `npy` file. + + One can use also any other type of array with `elfi.store.BatchArrayStore`. The only + requirement is that the array supports Python list indexing to access the data.""" + def __init__(self, outputs, name='arraypool', basepath=None): - """Creates a pool object that makes it easy to add `NpyPersistedArray` based stores. - + """ + Parameters ---------- outputs : list - name of outputs to store. `ArrayPool` will create a `NpyPersistedArray':s - for each output. + name of nodes whose output to store to a numpy .npy file. name : str - Name of the store. This will be part of the path where the arrays are stored. + Name of the pool. This will be part of the path where the data are stored. basepath : str - Path to directory under which `ArrayPool` will place its folders and files. - Default is ~/.elfi + Path to directory under which `elfi.ArrayPool` will place its folders and + files. Default is ~/.elfi Returns ------- @@ -188,7 +215,7 @@ def path(self): return os.path.join(self.basepath, self.name, str(self.seed)) def delete(self): - """Removes the folder and files of this store.""" + """Removes the folder and all the data in this pool.""" try: path = self.path except: @@ -198,15 +225,15 @@ def delete(self): shutil.rmtree(path) def close(self): - """Closes NpyPersistedArrays based stores.""" + """Closes the array files of the stores.""" for store in self.output_stores.values(): - if isinstance(store, BatchArrayStore) and hasattr(store.array, 'close'): + if hasattr(store, 'array') and hasattr(store.array, 'close'): store.array.close() def flush(self): - """Flushes NpyPersistedArrays based stores.""" + """Flushes all array files of the stores.""" for store in self.output_stores.values(): - if isinstance(store, BatchArrayStore) and hasattr(store.array, 'flush'): + if hasattr(store, 'array') and hasattr(store.array, 'flush'): store.array.flush() @@ -233,17 +260,31 @@ def clear(self): raise NotImplementedError -# TODO: add mask for missing items +# TODO: add mask for missing items. It should replace the use of `current_index`. +# This should make it possible to also append further than directly to the end +# of current index or length of the array. class BatchArrayStore(BatchStore): - """Helper class to handle arrays as batch dictionaries""" - def __init__(self, array, batch_size): + """Helper class to use arrays as data stores in ELFI""" + def __init__(self, array, batch_size, n_batches=0): + """ + + Parameters + ---------- + array + Any array like object supporting Python list indexing + batch_size : int + Size of a batch of data + n_batches : int + When using pre allocated arrays, this keeps track of the number of batches + currently stored to the array. + """ self.array = array self.batch_size = batch_size + self.n_batches = n_batches def __contains__(self, batch_index): - # TODO: implement a mask b = self._to_slice(batch_index).stop - return b <= len(self.array) + return batch_index < self.n_batches and b <= len(self.array) def __getitem__(self, batch_index): sl = self._to_slice(batch_index) @@ -251,21 +292,36 @@ def __getitem__(self, batch_index): def __setitem__(self, batch_index, data): sl = self._to_slice(batch_index) + if batch_index in self: self.array[sl] = data - elif sl.start == len(self.array): - # TODO: allow appending further than directly to the end - if hasattr(self.array, 'append'): + elif batch_index == self.n_batches: + # Append a new batch + if sl.stop <= len(self.array): + self.array[sl] = data + elif sl.start == len(self.array) and hasattr(self.array, 'append'): + # NpyPersistedArray supports appending self.array.append(data) + else: + raise ValueError("There is not enough space in the array") + self.n_batches += 1 else: - raise ValueError('Cannot append to array') + raise ValueError("Appending further than the end of the array is not yet " + "supported") def __delitem__(self, batch_index): - sl = self._to_slice(batch_index) - if sl.stop == len(self.array): + if batch_index not in self: + raise IndexError("Cannot remove, batch index {} is not in the array" + .format(batch_index)) + elif batch_index != self.n_batches: + raise IndexError("It is not yet possible to remove batches from the middle " + "of the array") + + if hasattr(self.array, 'truncate'): + sl = self._to_slice(batch_index) self.array.truncate(sl.start) - else: - raise IndexError('It is not yet possible to remove from the middle of an array') + + self.n_batches -= 1 def __len__(self): return int(len(self.array)/self.batch_size) @@ -277,6 +333,7 @@ def _to_slice(self, batch_index): def clear(self): if hasattr(self.array, 'clear'): self.array.clear() + self.n_batches = 0 class NpyPersistedArray: diff --git a/elfi/visualization/visualization.py b/elfi/visualization/visualization.py index 72f879f9..fa844355 100644 --- a/elfi/visualization/visualization.py +++ b/elfi/visualization/visualization.py @@ -7,9 +7,7 @@ def nx_draw(G, internal=False, param_names=False, filename=None, format=None): """ - Return a GraphViz dot representation of the model. - - Requires the optional 'graphviz' library. + Draw the `ElfiModel`. Parameters ---------- @@ -23,6 +21,16 @@ def nx_draw(G, internal=False, param_names=False, filename=None, format=None): If given, save the dot file into the given filename. format : str, optional format of the file + + Notes + ----- + Requires the optional 'graphviz' library. + + Returns + ------- + dot + A GraphViz dot representation of the model. + """ try: from graphviz import Digraph From bd7b1bcdbdd048c60ac3abba0d62181eabbbe72a Mon Sep 17 00:00:00 2001 From: Henri Vuollekoski Date: Wed, 7 Jun 2017 18:28:24 +0300 Subject: [PATCH 06/38] Unsigned int32 doesn't seem to work in Windows 10: changed to signed (#169) --- elfi/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/elfi/utils.py b/elfi/utils.py index 19435931..aa7c2000 100644 --- a/elfi/utils.py +++ b/elfi/utils.py @@ -36,7 +36,7 @@ def nbunch_ancestors(G, nbunch): return ancestors -def get_sub_seed(random_state, sub_seed_index, high=2**32): +def get_sub_seed(random_state, sub_seed_index, high=2**31): """Returns a sub seed. The returned sub seed is unique for its index, i.e. no two indexes can return the same sub_seed. Same random_state will also always produce the same sequence. From 5c5c7830a505e5ae94eedd1e9a89d892c99b9fa8 Mon Sep 17 00:00:00 2001 From: Henri Vuollekoski Date: Wed, 14 Jun 2017 18:43:52 +0300 Subject: [PATCH 07/38] Example: Ricker model (#173) --- elfi/examples/__init__.py | 3 +- elfi/examples/ricker.py | 155 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 157 insertions(+), 1 deletion(-) create mode 100644 elfi/examples/ricker.py diff --git a/elfi/examples/__init__.py b/elfi/examples/__init__.py index 3e9cd7ae..950484f4 100644 --- a/elfi/examples/__init__.py +++ b/elfi/examples/__init__.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- from elfi.examples import ma2 -from elfi.examples import bdm \ No newline at end of file +from elfi.examples import bdm +from elfi.examples import ricker diff --git a/elfi/examples/ricker.py b/elfi/examples/ricker.py new file mode 100644 index 00000000..0d24f6f8 --- /dev/null +++ b/elfi/examples/ricker.py @@ -0,0 +1,155 @@ +from functools import partial +import numpy as np +import scipy.stats as ss +import elfi + + +"""Example implementation of the Ricker model. +""" + +def ricker(log_rate, stock_init=1., n_obs=50, batch_size=1, random_state=None): + """The Ricker model. + + Ricker, W. E. (1954) Stock and Recruitment Journal of the Fisheries + Research Board of Canada, 11(5): 559-623. + + Parameters + ---------- + log_rate : float or np.array + Log growth rate of population. + stock_init : float or np.array, optional + Initial stock. + n_obs : int, optional + batch_size : int, optional + random_state : np.random.RandomState, optional + + Returns + ------- + stock : np.array + """ + random_state = random_state or np.random + + stock = np.empty((batch_size, n_obs)) + stock[:, 0] = stock_init + + for ii in range(1, n_obs): + stock[:, ii] = stock[:, ii-1] * np.exp(log_rate - stock[:, ii-1]) + + return stock + + +def stochastic_ricker(log_rate, std, scale, stock_init=1., n_obs=50, batch_size=1, random_state=None): + """Ricker model with observed stock ~ Poisson(true stock * scaling). + + Parameters + ---------- + log_rate : float or np.array + Log growth rate of population. + std : float or np.array + Standard deviation of innovations. + scale : float or np.array + Scaling of the expected value from Poisson distribution. + stock_init : float or np.array, optional + Initial stock. + n_obs : int, optional + batch_size : int, optional + random_state : np.random.RandomState, optional + + Returns + ------- + stock_obs : np.array + """ + random_state = random_state or np.random + + stock_obs = np.empty((batch_size, n_obs)) + stock_prev = stock_init + + for ii in range(n_obs): + stock = stock_prev * np.exp(log_rate - stock_prev + std * random_state.randn(batch_size)) + stock_prev = stock + + # the observed stock is Poisson distributed + stock_obs[:, ii] = random_state.poisson(scale * stock, batch_size) + + return stock_obs + + +def get_model(n_obs=50, true_params=None, seed_obs=None, stochastic=True): + """Returns a complete Ricker model in inference task. + + This is a simplified example that achieves reasonable predictions. For more extensive treatment + and description using 13 summary statistics, see: + + Wood, S. N. (2010) Statistical inference for noisy nonlinear ecological dynamic systems, + Nature 466, 1102–1107. + + Parameters + ---------- + n_obs : int, optional + Number of observations. + true_params : list, optional + Parameters with which the observed data is generated. + seed_obs : None, int, optional + Seed for the observed data generation. + stochastic : bool, optional + Whether to use the stochastic or deterministic Ricker model. + + Returns + ------- + m : elfi.ElfiModel + """ + + if stochastic: + simulator = partial(stochastic_ricker, n_obs=n_obs) + if true_params is None: + true_params = [3.8, 0.3, 10.] + + else: + simulator = partial(ricker, n_obs=n_obs) + if true_params is None: + true_params = [3.8] + + m = elfi.ElfiModel(set_current=False) + y_obs = simulator(*true_params, n_obs=n_obs, random_state=np.random.RandomState(seed_obs)) + sim_fn = partial(simulator, n_obs=n_obs) + sumstats = [] + + if stochastic: + elfi.Prior(ss.expon, np.e, 2, model=m, name='t1') + elfi.Prior(ss.truncnorm, 0, 5, model=m, name='t2') + elfi.Prior(ss.uniform, 0, 100, model=m, name='t3') + elfi.Simulator(sim_fn, m['t1'], m['t2'], m['t3'], observed=y_obs, name='Ricker') + sumstats.append(elfi.Summary(partial(np.mean, axis=1), m['Ricker'], name='Mean')) + sumstats.append(elfi.Summary(partial(np.var, axis=1), m['Ricker'], name='Var')) + sumstats.append(elfi.Summary(num_zeros, m['Ricker'], name='#0')) + elfi.Discrepancy(chi_squared, *sumstats, name='d') + + else: # very simple deterministic case + elfi.Prior(ss.expon, np.e, model=m, name='t1') + elfi.Simulator(sim_fn, m['t1'], observed=y_obs, name='Ricker') + sumstats.append(elfi.Summary(partial(np.mean, axis=1), m['Ricker'], name='Mean')) + elfi.Distance('euclidean', *sumstats, name='d') + + return m + + +def chi_squared(*simulated, observed): + """Chi squared goodness of fit. + Adjusts for differences in magnitude between dimensions. + + Parameters + ---------- + simulated : np.arrays + observed : tuple of np.arrays + """ + simulated = np.column_stack(simulated) + observed = np.column_stack(observed) + d = np.sum((simulated - observed)**2. / observed, axis=1) + return d + + +def num_zeros(x): + """A summary statistic: number of zero observations. + """ + n = np.sum(x == 0, axis=1) + return n From 7c07f00615985d5f71d427df7138d37829cccc45 Mon Sep 17 00:00:00 2001 From: Arijus Date: Thu, 15 Jun 2017 16:15:37 +0300 Subject: [PATCH 08/38] Implementing an example of the Gaussian noise model. (#172) * Implementing an example of the Gaussian noise model. * Addressing the first code review's issues related to the Gaussian noise model. * Addressing the second code review's issues related to the Gaussian noise model. * Addressing the computational cost of the example: the number of the simulations and the bounds of the priors. --- elfi/examples/__init__.py | 1 + elfi/examples/gauss.py | 67 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 elfi/examples/gauss.py diff --git a/elfi/examples/__init__.py b/elfi/examples/__init__.py index 950484f4..977acbdd 100644 --- a/elfi/examples/__init__.py +++ b/elfi/examples/__init__.py @@ -2,4 +2,5 @@ from elfi.examples import ma2 from elfi.examples import bdm +from elfi.examples import gauss from elfi.examples import ricker diff --git a/elfi/examples/gauss.py b/elfi/examples/gauss.py new file mode 100644 index 00000000..512de690 --- /dev/null +++ b/elfi/examples/gauss.py @@ -0,0 +1,67 @@ +import numpy as np +import scipy.stats as ss +import elfi +from functools import partial + + +"""An example implementation of a Gaussian noise model. +""" + + +def Gauss(mu, sigma, n_obs=20, batch_size=1, random_state=None): + # Standardising the parameter's format. + mu = np.asanyarray(mu).reshape((-1, 1)) + sigma = np.asanyarray(sigma).reshape((-1, 1)) + y = ss.norm.rvs(loc=mu, scale=sigma, size=(batch_size, n_obs), + random_state=random_state) + return y + + +def ss_mean(x): + """The summary statistic corresponding to the mean. + """ + ss = np.mean(x, axis=1) + return ss + + +def ss_var(x): + """The summary statistic corresponding to the variance. + """ + ss = np.var(x, axis=1) + return ss + + +def get_model(n_obs=20, true_params=None, seed_obs=None): + """Returns a complete Gaussian noise model + + Parameters + ---------- + n_obs : int + the number of observations + true_params : list + true_params[0] corresponds to the mean, + true_params[1] corresponds to the standard deviation + seed_obs : None, int + seed for the observed data generation + + Returns + ------- + m : elfi.ElfiModel + """ + + if true_params is None: + true_params = [10, 2] + + y_obs = Gauss(*true_params, n_obs=n_obs, + random_state=np.random.RandomState(seed_obs)) + sim_fn = partial(Gauss, n_obs=n_obs) + + m = elfi.ElfiModel(set_current=False) + elfi.Prior('uniform', -1e2, 1e2, model=m, name='mu') + elfi.Prior('truncnorm', 1e-1, 1e1, model=m, name='sigma') + elfi.Simulator(sim_fn, m['mu'], m['sigma'], observed=y_obs, name='Gauss') + elfi.Summary(ss_mean, m['Gauss'], name='S1') + elfi.Summary(ss_var, m['Gauss'], name='S2') + elfi.Distance('euclidean', m['S1'], m['S2'], name='d') + + return m From e47630f8c562cea7470f766b8378030d51c02849 Mon Sep 17 00:00:00 2001 From: Henri Vuollekoski Date: Thu, 15 Jun 2017 17:38:22 +0300 Subject: [PATCH 09/38] Fix too narrow prior, add simple tests for Gauss and Ricker (#176) * Fix too narrow prior * Add simple tests for Gauss and Ricker --- elfi/examples/gauss.py | 8 ++++---- tests/unit/test_examples.py | 11 +++++++++++ 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/elfi/examples/gauss.py b/elfi/examples/gauss.py index 512de690..b732a145 100644 --- a/elfi/examples/gauss.py +++ b/elfi/examples/gauss.py @@ -8,7 +8,7 @@ """ -def Gauss(mu, sigma, n_obs=20, batch_size=1, random_state=None): +def Gauss(mu, sigma, n_obs=50, batch_size=1, random_state=None): # Standardising the parameter's format. mu = np.asanyarray(mu).reshape((-1, 1)) sigma = np.asanyarray(sigma).reshape((-1, 1)) @@ -31,7 +31,7 @@ def ss_var(x): return ss -def get_model(n_obs=20, true_params=None, seed_obs=None): +def get_model(n_obs=50, true_params=None, seed_obs=None): """Returns a complete Gaussian noise model Parameters @@ -57,8 +57,8 @@ def get_model(n_obs=20, true_params=None, seed_obs=None): sim_fn = partial(Gauss, n_obs=n_obs) m = elfi.ElfiModel(set_current=False) - elfi.Prior('uniform', -1e2, 1e2, model=m, name='mu') - elfi.Prior('truncnorm', 1e-1, 1e1, model=m, name='sigma') + elfi.Prior('uniform', -10, 50, model=m, name='mu') + elfi.Prior('truncnorm', 0.01, 5, model=m, name='sigma') elfi.Simulator(sim_fn, m['mu'], m['sigma'], observed=y_obs, name='Gauss') elfi.Summary(ss_mean, m['Gauss'], name='S1') elfi.Summary(ss_var, m['Gauss'], name='S2') diff --git a/tests/unit/test_examples.py b/tests/unit/test_examples.py index 716184ed..335b2d9e 100644 --- a/tests/unit/test_examples.py +++ b/tests/unit/test_examples.py @@ -32,3 +32,14 @@ def test_bdm(recwarn): os.system('rm ./bdm') + +def test_Gauss(): + m = ee.gauss.get_model() + rej = elfi.Rejection(m, m['d'], batch_size=10) + rej.sample(20) + + +def test_Ricker(): + m = ee.ricker.get_model() + rej = elfi.Rejection(m, m['d'], batch_size=10) + rej.sample(20) From 32ce8f6eb04b24108d7ade71bcf8450301f7d998 Mon Sep 17 00:00:00 2001 From: Henri Vuollekoski Date: Mon, 19 Jun 2017 13:31:13 +0300 Subject: [PATCH 10/38] Improvements in BO/BOLFI (#167) * Improvements in BO/BOLFI: use seed, priors, numerical gradients (if needed), L-BFGS-B-based optimization * Address comments to PR 167 --- .travis.yml | 3 +- CHANGELOG.rst | 9 +- README.md | 83 +++++++-- elfi/examples/ma2.py | 4 +- elfi/methods/bo/acquisition.py | 92 +++++++-- elfi/methods/bo/gpy_regression.py | 10 +- elfi/methods/bo/utils.py | 92 ++++++++- elfi/methods/mcmc.py | 3 +- elfi/methods/methods.py | 59 ++---- elfi/methods/posteriors.py | 288 +++++++++++++++++++++++++++++ elfi/methods/results.py | 203 -------------------- elfi/model/elfi_model.py | 2 +- elfi/model/extensions.py | 26 ++- requirements.txt | 5 +- scripts/MA2_run.py | 4 +- tests/conftest.py | 6 + tests/functional/test_inference.py | 34 ++-- tests/unit/test_examples.py | 4 + tests/unit/test_methods.py | 44 +++++ tests/unit/test_utils.py | 23 ++- 20 files changed, 680 insertions(+), 314 deletions(-) create mode 100644 elfi/methods/posteriors.py diff --git a/.travis.yml b/.travis.yml index 3baf91bf..3ed87940 100644 --- a/.travis.yml +++ b/.travis.yml @@ -12,4 +12,5 @@ install: # command to run tests script: - ipcluster start -n 2 --daemon - - travis_wait 20 make test + #- travis_wait 20 make test + - make test diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 56b9a6f2..70fb1906 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,6 +1,13 @@ -Change Log +Changelog ========== +0.5.x +----- + +- BO/BOLFI: take advantage of priors +- BO/BOLFI: take advantage of seed +- BO/BOLFI: improved optimization scheme + 0.5 (2017-05-19) ---------------- diff --git a/README.md b/README.md index 1781b78c..2c15a7c1 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,83 @@ -**Version 0.5 released!** This introduces many new features and small but significant -*changes in syntax. See the CHANGELOG and -*[notebooks](https://github.com/elfi-dev/notebooks). - +**Version 0.5 released!** This introduces many new features and small but significant changes in syntax. See the +CHANGELOG and [notebooks](https://github.com/elfi-dev/notebooks). ELFI - Engine for Likelihood-Free Inference =========================================== [![Build Status](https://travis-ci.org/elfi-dev/elfi.svg?branch=master)](https://travis-ci.org/elfi-dev/elfi) -[![Code Health](https://landscape.io/github/elfi-dev/elfi/master/landscape.svg?style=flat)](https://landscape.io/github/elfi-dev/elfi/master) +[![Code Health](https://landscape.io/github/elfi-dev/elfi/dev/landscape.svg?style=flat)](https://landscape.io/github/elfi-dev/elfi/dev) [![Documentation Status](https://readthedocs.org/projects/elfi/badge/?version=latest)](http://elfi.readthedocs.io/en/latest/?badge=latest) [![Gitter](https://badges.gitter.im/elfi-dev/elfi.svg)](https://gitter.im/elfi-dev/elfi?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) -ELFI is a statistical software package for likelihood-free inference (LFI) such as -Approximate Bayesian Computation -([ABC](https://en.wikipedia.org/wiki/Approximate_Bayesian_computation)). +ELFI is a statistical software package written in Python for likelihood-free inference (LFI) such as Approximate +Bayesian Computation ([ABC](https://en.wikipedia.org/wiki/Approximate_Bayesian_computation)). +The term LFI refers to a family of inference methods that replace the use of the likelihood function with a data +generating simulator function. ELFI features an easy to use generative modeling syntax and supports parallelized +inference out of the box. + +Currently implemented LFI methods: +- ABC Rejection sampler +- Sequential Monte Carlo ABC sampler +- [Bayesian Optimization for Likelihood-Free Inference (BOLFI)](http://jmlr.csail.mit.edu/papers/v17/15-017.html) + +Other notable included algorithms and methods: +- Bayesian Optimization +- [No-U-Turn-Sampler](http://jmlr.org/papers/volume15/hoffman14a/hoffman14a.pdf), a Hamiltonian Monte Carlo MCMC sampler -See the [documentation](http://elfi.readthedocs.io/) for more details. We also have -several [jupyter notebooks](https://github.com/elfi-dev/notebooks) available to -get you going quickly. Limited user-support may be asked from elfi-support.at.hiit.fi, but -the [Gitter chat](https://gitter.im/elfi-dev/elfi?utm_source=share-link&utm_medium=link&utm_campaign=share-link) +See examples under [notebooks](https://github.com/elfi-dev/notebooks) to get started. Full +documentation can be found at http://elfi.readthedocs.io/. Limited user-support may be +asked from elfi-support.at.hiit.fi, but the +[Gitter chat](https://gitter.im/elfi-dev/elfi?utm_source=share-link&utm_medium=link&utm_campaign=share-link) is preferable. + + +Installation +------------ + +ELFI requires and is tested with Python 3.5-3.6. You can install ELFI by typing in your terminal: + +``` +pip install elfi +``` +or on some platforms using Python 3 specific syntax: +``` +pip3 install elfi +``` + +Note that in some environments you may need to first install `numpy` with +`pip install numpy`. This is due to our dependency to `GPy` that uses `numpy` in its installation. + +### Optional dependencies + +- `graphviz` for drawing graphical models (needs [Graphviz](http://www.graphviz.org)), highly recommended + + +### Installing Python 3 + +If you are new to Python, perhaps the simplest way to install a specific version of Python +is with [Anaconda](https://www.continuum.io/downloads). + +### Virtual environment using Anaconda + +It is very practical to create a virtual Python environment. This way you won't interfere +with your default Python environment and can easily use different versions of Python +in different projects. You can create a virtual environment for ELFI using anaconda with: + +``` +conda create -n elfi python=3.5 numpy +source activate elfi +pip install elfi +``` + +### Potential problems with installation + +ELFI depends on several other Python packages, which have their own dependencies. +Resolving these may sometimes go wrong: +- If you receive an error about missing `numpy`, please install it first. +- If you receive an error about `yaml.load`, install `pyyaml`. +- On OS X with Anaconda virtual environment say `conda install python.app` and then use +`pythonw` instead of `python`. +- Note that ELFI currently supports Python 3.5-3.6 only, although 3.x may work as well, +so try `pip3 install elfi`. diff --git a/elfi/examples/ma2.py b/elfi/examples/ma2.py index df1ad3f8..37ab1ac6 100644 --- a/elfi/examples/ma2.py +++ b/elfi/examples/ma2.py @@ -72,7 +72,7 @@ def get_model(n_obs=100, true_params=None, seed_obs=None): # Define prior t1 as in Marin et al., 2012 with t1 in range [-b, b] -class CustomPrior1: +class CustomPrior1(elfi.Distribution): @classmethod def rvs(cls, b, size=1, random_state=None): u = ss.uniform.rvs(loc=0, scale=1, size=size, random_state=random_state) @@ -88,7 +88,7 @@ def pdf(cls, x, b): # Define prior t2 conditionally on t1 as in Marin et al., 2012, in range [-a, a] -class CustomPrior2: +class CustomPrior2(elfi.Distribution): @classmethod def rvs(cls, t1, a, size=1, random_state=None): """ diff --git a/elfi/methods/bo/acquisition.py b/elfi/methods/bo/acquisition.py index d15be4af..ba8d1f3f 100644 --- a/elfi/methods/bo/acquisition.py +++ b/elfi/methods/bo/acquisition.py @@ -3,12 +3,12 @@ import numpy as np from scipy.stats import uniform, multivariate_normal -from elfi.methods.bo.utils import stochastic_optimization +from elfi.methods.bo.utils import minimize + logger = logging.getLogger(__name__) # TODO: make a faster optimization method utilizing parallelization (see e.g. GPyOpt) -# TODO: make use of random_state class AcquisitionBase: @@ -23,28 +23,54 @@ class AcquisitionBase: bounds : tuple of length 'input_dim' of tuples (min, max) and methods evaluate(x) : function that returns model (mean, var, std) - n_samples : None or int - Total number of samples to be sampled, used when part of an - AcquisitionSchedule object (None indicates no upper bound) + priors : list of elfi.Priors, optional + By default uniform distribution within model bounds. + n_inits : int, optional + Number of initialization points in internal optimization. + max_opt_iters : int, optional + Max iterations to optimize when finding the next point. + noise_cov : float or np.array, optional + Covariance of the added noise. If float, multiplied by identity matrix. + seed : int """ - def __init__(self, model, max_opt_iter=1000, noise_cov=0.): + def __init__(self, model, priors=None, n_inits=10, max_opt_iters=1000, noise_cov=0., seed=0): self.model = model - self.max_opt_iter = int(max_opt_iter) + self.n_inits = n_inits + self.max_opt_iters = int(max_opt_iters) + + # TODO: change input to more generic get_initial_points method + if priors is None: + self.priors = [None] * model.input_dim + else: + self.priors = priors if isinstance(noise_cov, (float, int)): noise_cov = np.eye(self.model.input_dim) * noise_cov self.noise_cov = noise_cov + self.random_state = np.random.RandomState(seed) + def evaluate(self, x, t=None): - """Evaluates the acquisition function value at 'x' + """Evaluates the acquisition function value at 'x'. - Returns - ------- + Parameters + ---------- x : numpy.array t : int current iteration (starting from 0) """ - return NotImplementedError + raise NotImplementedError + + def evaluate_grad(self, x, t=None): + """Evaluates the gradient of acquisition function value at 'x'. + + Parameters + ---------- + x : numpy.array + t : int + Current iteration (starting from 0). + """ + raise NotImplementedError def acquire(self, n_values, pending_locations=None, t=None): """Returns the next batch of acquisition points. @@ -60,7 +86,7 @@ def acquire(self, n_values, pending_locations=None, t=None): use the locations in choosing the next sampling location. Locations should be in rows. t : int - Current iteration (starting from 0) + Current iteration (starting from 0). Returns ------- @@ -70,11 +96,13 @@ def acquire(self, n_values, pending_locations=None, t=None): logger.debug('Acquiring {} values'.format(n_values)) obj = lambda x: self.evaluate(x, t) - minloc, val = stochastic_optimization(obj, self.model.bounds, self.max_opt_iter) - + grad_obj = lambda x: self.evaluate_grad(x, t) + minloc, minval = minimize(obj, grad_obj, self.model.bounds, self.priors, self.n_inits, self.max_opt_iters) x = np.tile(minloc, (n_values, 1)) - x += multivariate_normal.rvs(cov=self.noise_cov, size=n_values).reshape((n_values, -1)) + # add some noise for more efficient exploration + x += multivariate_normal.rvs(cov=self.noise_cov, size=n_values, random_state=self.random_state) \ + .reshape((n_values, -1)) # make sure the acquired points stay within bounds for ii in range(self.model.input_dim): @@ -109,25 +137,49 @@ class LCBSC(AcquisitionBase): would be t**(2d + 2). """ - def __init__(self, *args, delta=.1, **kwargs): + def __init__(self, *args, delta=0.1, **kwargs): super(LCBSC, self).__init__(*args, **kwargs) if delta <= 0 or delta >= 1: raise ValueError('Parameter delta must be in the interval (0,1)') self.delta = delta - def beta(self, t): + def _beta(self, t): # Start from 0 t += 1 d = self.model.input_dim return 2*np.log(t**(2*d + 2) * np.pi**2 / (3*self.delta)) def evaluate(self, x, t=None): - """ Lower confidence bound selection criterion = mean - sqrt(\beta_t) * std """ + """Lower confidence bound selection criterion: + + mean - sqrt(\beta_t) * std + + Parameters + ---------- + x : numpy.array + t : int + Current iteration (starting from 0). + """ if not isinstance(t, int): raise ValueError("Parameter 't' should be an integer.") mean, var = self.model.predict(x, noiseless=True) - return mean - np.sqrt(self.beta(t) * var) + return mean - np.sqrt(self._beta(t) * var) + + def evaluate_grad(self, x, t=None): + """Gradient of the lower confidence bound selection criterion. + + Parameters + ---------- + x : numpy.array + t : int + Current iteration (starting from 0). + """ + mean, var = self.model.predict(x, noiseless=True) + grad_mean, grad_var = self.model.predictive_gradients(x) + grad_mean = grad_mean[:, :, 0] # assume 1D output + + return grad_mean - 0.5 * grad_var * np.sqrt(self._beta(t) / var) class UniformAcquisition(AcquisitionBase): @@ -135,4 +187,4 @@ class UniformAcquisition(AcquisitionBase): def acquire(self, n_values, pending_locations=None, t=None): bounds = np.stack(self.model.bounds) return uniform(bounds[:,0], bounds[:,1] - bounds[:,0])\ - .rvs(size=(n_values, self.model.input_dim)) + .rvs(size=(n_values, self.model.input_dim), random_state=self.random_state) diff --git a/elfi/methods/bo/gpy_regression.py b/elfi/methods/bo/gpy_regression.py index 11269493..042e67be 100644 --- a/elfi/methods/bo/gpy_regression.py +++ b/elfi/methods/bo/gpy_regression.py @@ -1,8 +1,9 @@ # TODO: make own general GPRegression and kernel classes import logging -import numpy as np import copy + +import numpy as np import GPy logger = logging.getLogger(__name__) @@ -178,6 +179,11 @@ def predictive_gradients(self, x): return self._gp.predictive_gradients(x) + def predictive_gradient_mean(self, x): + """Return the gradient of the GP model mean at x. + """ + return self.predictive_gradients(x)[0][:, :, 0] + def _init_gp(self, x, y): self._kernel_is_default = False @@ -274,5 +280,3 @@ def copy(self): kopy.gp_params['mean_function'] = self.gp_params['mean_function'].copy() return kopy - - diff --git a/elfi/methods/bo/utils.py b/elfi/methods/bo/utils.py index c2b9d7e8..c5388b19 100644 --- a/elfi/methods/bo/utils.py +++ b/elfi/methods/bo/utils.py @@ -1,5 +1,6 @@ import numpy as np -from scipy.optimize import differential_evolution +import numdifftools +from scipy.optimize import differential_evolution, fmin_l_bfgs_b def approx_second_partial_derivative(fun, x0, dim, h, bounds): @@ -53,9 +54,94 @@ def sum_of_rbf_kernels(point, kern_centers, kern_ampl, kern_scale): return ret -def stochastic_optimization(fun, bounds, maxiter=1000, polish=True): +def stochastic_optimization(fun, bounds, maxiter=1000, polish=True, seed=0): """ Called to find the minimum of function 'fun' in 'maxiter' iterations """ result = differential_evolution(func=fun, bounds=bounds, maxiter=maxiter, - polish=polish, init='latinhypercube') + polish=polish, init='latinhypercube', seed=seed) return result.x, result.fun + +def minimize(fun, grad, bounds, priors, n_inits=10, maxiter=1000, random_state=None): + """ Called to find the minimum of function 'fun'. + + Parameters + ---------- + fun : callable + Function to minimize. + grad : callable + Gradient of fun. + bounds : list of tuples + Bounds for each parameter. + priors : list of elfi.Priors, or list of Nones + Used for sampling initialization points. If Nones, sample uniformly. + n_inits : int, optional + Number of initialization points. + maxiter : int, optional + Maximum number of iterations. + random_state : np.random.RandomState, optional + Used only if no elfi.Priors given. + + Returns + ------- + tuple of the found coordinates of minimum and the corresponding value. + """ + inits = np.empty((n_inits, len(priors))) + + # TODO: change input to more generic get_initial_points method + if priors[0] is None: + # Sample initial points uniformly within bounds + random_state = random_state or np.random.RandomState() + for ii in range(len(priors)): + inits[:, ii] = random_state.uniform(*bounds[ii], n_inits) + + else: + # Sample priors for initialization points + prior_names = [p.name for p in priors] + inits_dict = priors[0].model.generate(n_inits, outputs=prior_names) + for ii, n in enumerate(prior_names): + inits[:, ii] = inits_dict[n] + inits[:, ii] = np.clip(inits[:, ii], *bounds[ii]) + + locs = [] + vals = np.empty(n_inits) + + # Run optimization from each initialization point + for ii in range(n_inits): + result = fmin_l_bfgs_b(fun, inits[ii, :], fprime=grad, bounds=bounds, maxiter=maxiter) + locs.append(result[0]) + vals[ii] = result[1] + + # Return the optimal case + ind_min = np.argmin(vals) + return locs[ind_min], vals[ind_min] + + +def numerical_gradient_logpdf(x, *params, distribution=None, **kwargs): + """Gradient of the log of the probability density function at x. + + Approximated numerically. + + Parameters + ---------- + x : array_like + points where to evaluate the gradient + param1, param2, ... : array_like + parameters of the model + distribution : ScipyLikeDistribution or a distribution from Scipy + + Returns + ------- + grad_logpdf : ndarray + Gradient of the log of the probability density function evaluated at x + """ + + # due to the common scenario of logpdf(x) = -inf, multiple confusing warnings could be generated + with np.warnings.catch_warnings(): + np.warnings.filterwarnings('ignore') + if np.isinf(distribution.logpdf(x, *params, **kwargs)): + grad = np.zeros_like(x) # logpdf = -inf => grad = 0 + else: + grad = numdifftools.Gradient(distribution.logpdf)(x, *params, **kwargs) + grad = np.where(np.isnan(grad), 0, grad) + + return grad diff --git a/elfi/methods/mcmc.py b/elfi/methods/mcmc.py index 60a00bde..4e1cc2fb 100644 --- a/elfi/methods/mcmc.py +++ b/elfi/methods/mcmc.py @@ -1,6 +1,7 @@ -import numpy as np import logging +import numpy as np + logger = logging.getLogger(__name__) diff --git a/elfi/methods/methods.py b/elfi/methods/methods.py index 06931864..e65e5d4e 100644 --- a/elfi/methods/methods.py +++ b/elfi/methods/methods.py @@ -17,7 +17,8 @@ from elfi.methods.bo.acquisition import LCBSC from elfi.methods.bo.gpy_regression import GPyRegression from elfi.methods.bo.utils import stochastic_optimization -from elfi.methods.results import BolfiPosterior, Result, ResultSMC, ResultBOLFI +from elfi.methods.results import Result, ResultSMC, ResultBOLFI +from elfi.methods.posteriors import BolfiPosterior from elfi.methods.utils import GMDistribution, weighted_var from elfi.model.elfi_model import ComputationContext, NodeReference, Operation, ElfiModel from elfi.utils import args_to_tuple @@ -514,7 +515,8 @@ def extract_result(self): discrepancy_name=self.discrepancy, threshold=self.state['threshold'], n_sim=self.state['n_sim'], - accept_rate=self.state['accept_rate'] + accept_rate=self.state['accept_rate'], + seed=self.seed ) return result @@ -626,6 +628,7 @@ def extract_result(self): threshold=self.state['threshold'], n_sim=self.state['n_sim'], accept_rate=self.state['accept_rate'], + seed=self.seed, populations=self._populations.copy() + [pop] ) @@ -737,7 +740,7 @@ class BayesianOptimization(InferenceMethod): def __init__(self, model, target=None, outputs=None, batch_size=1, initial_evidence=None, update_interval=10, bounds=None, target_model=None, - acquisition_method=None, **kwargs): + acquisition_method=None, acq_noise_cov=1., **kwargs): """ Parameters ---------- @@ -747,6 +750,8 @@ def __init__(self, model, target=None, outputs=None, batch_size=1, target_model : GPyRegression, optional acquisition_method : Acquisition, optional Method of acquiring evidence points. Defaults to LCBSC. + acq_noise_cov : float, or np.array of shape (n_params, n_params), optional + Covariance of the noise added in the default LCBSC acquisition method. bounds : list The region where to estimate the posterior for each parameter in model.parameters. @@ -788,7 +793,10 @@ def __init__(self, model, target=None, outputs=None, batch_size=1, if initial_evidence % self.batch_size != 0: raise ValueError('Initial evidence must be divisible by the batch size') - self.acquisition_method = acquisition_method or LCBSC(target_model) + priors = [self.model[p] for p in self.parameters] + self.acquisition_method = acquisition_method or \ + LCBSC(target_model, priors=priors, + noise_cov=acq_noise_cov, seed=self.seed) # TODO: move some of these to objective self.n_evidence = initial_evidence @@ -959,41 +967,6 @@ class BOLFI(BayesianOptimization): http://jmlr.org/papers/v17/15-017.html """ - def __init__(self, model, target=None, outputs=None, batch_size=1, - initial_evidence=10, update_interval=10, bounds=None, target_model=None, - acquisition_method=None, acq_noise_cov=1., **kwargs): - """ - Parameters - ---------- - model : ElfiModel or NodeReference - target : str or NodeReference - Only needed if model is an ElfiModel - target_model : GPyRegression, optional - The discrepancy model. - acquisition_method : Acquisition, optional - Method of acquiring evidence points. Defaults to LCBSC with noise ~N(0,acq_noise_cov). - acq_noise_cov : float, or np.array of shape (n_params, n_params), optional - Covariance of the noise added in the default LCBSC acquisition method. - bounds : list - The region where to estimate the posterior for each parameter in - model.parameters. - `[(lower, upper), ... ]` - initial_evidence : int, dict - Number of initial evidence or a precomputed batch dict containing parameter - and discrepancy values - update_interval : int - How often to update the GP hyperparameters of the target_model - exploration_rate : float - Exploration rate of the acquisition method - """ - super(BOLFI, self).__init__(model=model, target=target, outputs=outputs, - batch_size=batch_size, - initial_evidence=initial_evidence, - update_interval=update_interval, bounds=bounds, - target_model=target_model, - acquisition_method=acquisition_method, **kwargs) - self.acquisition_method = acquisition_method or LCBSC(self.target_model, noise_cov=acq_noise_cov) - def fit(self, *args, **kwargs): """Fit the surrogate model (e.g. Gaussian process) to generate a regression @@ -1020,7 +993,8 @@ def infer_posterior(self, threshold=None): if self.state['n_batches'] == 0: self.fit() - return BolfiPosterior(self.target_model, threshold) + priors = [self.model[p] for p in self.parameters] + return BolfiPosterior(self.target_model, threshold=threshold, priors=priors) def sample(self, n_samples, warmup=None, n_chains=4, threshold=None, initials=None, @@ -1078,7 +1052,7 @@ def sample(self, n_samples, warmup=None, n_chains=4, threshold=None, initials=No for ii in range(n_chains): seed = get_sub_seed(random_state, ii) tasks_ids.append(self.client.apply(mcmc.nuts, n_samples, initials[ii], posterior.logpdf, - posterior.grad_logpdf, n_adapt=warmup, seed=seed, **kwargs)) + posterior.gradient_logpdf, n_adapt=warmup, seed=seed, **kwargs)) # get results from completed tasks or run sampling (client-specific) # TODO: support async @@ -1100,5 +1074,6 @@ def sample(self, n_samples, warmup=None, n_chains=4, threshold=None, initials=No parameter_names=self.parameters, warmup=warmup, threshold=float(posterior.threshold), - n_sim=self.state['n_sim'] + n_sim=self.state['n_sim'], + seed=self.seed ) diff --git a/elfi/methods/posteriors.py b/elfi/methods/posteriors.py new file mode 100644 index 00000000..74bc5418 --- /dev/null +++ b/elfi/methods/posteriors.py @@ -0,0 +1,288 @@ +import logging +import numpy as np +import scipy as sp + +import matplotlib +import matplotlib.pyplot as plt +from functools import partial + +import elfi +from elfi.methods.bo.utils import minimize, numerical_gradient_logpdf + + +logger = logging.getLogger(__name__) + + +class BolfiPosterior(object): + """ + Container for the approximate posterior in the BOLFI framework, where the likelihood + is defined as + + L \propto F((h - \mu) / \sigma) + + where F is the cdf of N(0,1), h is a threshold, and \mu and \sigma are the mean and (noisy) + standard deviation of the Gaussian process. + + Note that when using a log discrepancy, h should become log(h). + + References + ---------- + Gutmann M U, Corander J (2016). Bayesian Optimization for Likelihood-Free Inference + of Simulator-Based Statistical Models. JMLR 17(125):1−47, 2016. + http://jmlr.org/papers/v17/15-017.html + + Parameters + ---------- + model : object + Instance of the surrogate model, e.g. elfi.bo.gpy_regression.GPyRegression. + threshold : float, optional + The threshold value used in the calculation of the posterior, see the BOLFI paper for details. + By default, the minimum value of discrepancy estimate mean is used. + priors : list of elfi.Priors, optional + By default uniform distribution within model bounds. + n_inits : int, optional + Number of initialization points in internal optimization. + max_opt_iters : int, optional + Maximum number of iterations performed in internal optimization. + """ + + def __init__(self, model, threshold=None, priors=None, n_inits=10, max_opt_iters=1000, seed=0): + super(BolfiPosterior, self).__init__() + self.threshold = threshold + self.model = model + self.random_state = np.random.RandomState(seed) + self.n_inits = n_inits + self.max_opt_iters = max_opt_iters + + if priors is None: + self.priors = [None] * model.input_dim + else: + self.priors = priors + self._prepare_logprior_nets() + + if self.threshold is None: + minloc, minval = minimize(self.model.predict_mean, self.model.predictive_gradient_mean, + self.model.bounds, self.priors, self.n_inits, self.max_opt_iters, + random_state=self.random_state) + self.threshold = minval + logger.info("Using minimum value of discrepancy estimate mean (%.4f) as threshold" % (self.threshold)) + + @property + def ML(self): + """ + Maximum likelihood (ML) approximation. + + Returns + ------- + np.array + Maximum likelihood parameter values. + """ + x, lh_x = minimize(self._neg_unnormalized_loglikelihood, self._gradient_neg_unnormalized_loglikelihood, + self.model.bounds, self.priors, self.n_inits, self.max_opt_iters, + random_state=self.random_state) + return x + + @property + def MAP(self): + """ + Maximum a posteriori (MAP) approximation. + + Returns + ------- + np.array + Maximum a posteriori parameter values. + """ + x, post_x = minimize(self._neg_unnormalized_logposterior, self._gradient_neg_unnormalized_logposterior, + self.model.bounds, self.priors, self.n_inits, self.max_opt_iters, + random_state=self.random_state) + return x + + def logpdf(self, x): + """ + Returns the unnormalized log-posterior pdf at x. + + Parameters + ---------- + x : np.array + + Returns + ------- + float + """ + if not self._within_bounds(x): + return -np.inf + return self._unnormalized_loglikelihood(x) + self._logprior_density(x) + + def pdf(self, x): + """ + Returns the unnormalized posterior pdf at x. + + Parameters + ---------- + x : np.array + + Returns + ------- + float + """ + return np.exp(self.logpdf(x)) + + def gradient_logpdf(self, x): + """ + Returns the gradient of the unnormalized log-posterior pdf at x. + + Parameters + ---------- + x : np.array + + Returns + ------- + np.array + """ + grad = self._gradient_unnormalized_loglikelihood(x) + self._gradient_logprior_density(x) + return grad[0] + + def __getitem__(self, idx): + return tuple([[v]*len(idx) for v in self.MAP]) + + def _unnormalized_loglikelihood(self, x): + mean, var = self.model.predict(x) + if mean is None or var is None: + raise ValueError("Unable to evaluate model at %s" % (x)) + return sp.stats.norm.logcdf(self.threshold, mean, np.sqrt(var)) + + def _gradient_unnormalized_loglikelihood(self, x): + mean, var = self.model.predict(x) + if mean is None or var is None: + raise ValueError("Unable to evaluate model at %s" % (x)) + std = np.sqrt(var) + + grad_mean, grad_var = self.model.predictive_gradients(x) + grad_mean = grad_mean[:, :, 0] # assume 1D output + + factor = -grad_mean * std - (self.threshold - mean) * 0.5 * grad_var / std + factor = factor / var + term = (self.threshold - mean) / std + pdf = sp.stats.norm.pdf(term) + cdf = sp.stats.norm.cdf(term) + + return factor * pdf / cdf + + def _unnormalized_likelihood(self, x): + return np.exp(self._unnormalized_loglikelihood(x)) + + def _neg_unnormalized_loglikelihood(self, x): + return -1 * self._unnormalized_loglikelihood(x) + + def _gradient_neg_unnormalized_loglikelihood(self, x): + return -1 * self._gradient_unnormalized_loglikelihood(x) + + def _neg_unnormalized_logposterior(self, x): + return -1 * self.logpdf(x) + + def _gradient_neg_unnormalized_logposterior(self, x): + return -1 * self.gradient_logpdf(x) + + def _prepare_logprior_nets(self): + """Prepares graphs for calculating self._logprior_density and self._grad_logprior_density. + """ + self._client = elfi.clients.native.Client() # no need to parallelize here (sampling already parallel) + + # add numerical gradients, if necessary + for p in self.priors: + if not hasattr(p.distribution, 'gradient_logpdf'): + p.distribution.gradient_logpdf = partial(numerical_gradient_logpdf, distribution=p.distribution) + + # augment one model with logpdfs and another with their gradients + for target in ['logpdf', 'gradient_logpdf']: + model2 = self.priors[0].model.copy() + outputs = [] + for param in model2.parameters: + node = model2[param] + name = '_{}_{}'.format(target, param) + op = eval('node.distribution.' + target) + elfi.Operation(op, *([node] + node.parents), name=name, model=model2) + outputs.append(name) + + # compile and load the new net with data + context = model2.computation_context.copy() + context.batch_size = 1 # TODO: need more? + compiled_net = self._client.compile(model2.source_net, outputs) + loaded_net = self._client.load_data(compiled_net, context, batch_index=0) + + # remove requests for computation for priors (values assigned later) + for param in model2.parameters: + loaded_net.node[param].pop('operation') + + if target == 'logpdf': + self._logprior_net = loaded_net + else: + self._gradient_logprior_net = loaded_net + + def _logprior_density(self, x): + if self.priors[0] is None: + return 0 + + else: # need to evaluate the graph (up to priors) to account for potential hierarchies + + # load observations for priors + for i, p in enumerate(self.priors): + n = p.name + self._logprior_net.node[n]['output'] = x[i] + + # evaluate graph and return sum of logpdfs + res = self._client.compute(self._logprior_net) + return np.array([[sum([v for v in res.values()])]]) + + def _within_bounds(self, x): + x = x.reshape((-1, self.model.input_dim)) + for ii in range(self.model.input_dim): + if np.any(x[:, ii] < self.model.bounds[ii][0]) or np.any(x[:, ii] > self.model.bounds[ii][1]): + return False + return True + + def _gradient_logprior_density(self, x): + if self.priors[0] is None: + return 0 + + else: # need to evaluate the graph (up to priors) to account for potential hierarchies + + # load observations for priors + for i, p in enumerate(self.priors): + n = p.name + self._gradient_logprior_net.node[n]['output'] = x[i] + + # evaluate graph and return components of individual gradients of logpdfs (assumed independent!) + res = self._client.compute(self._gradient_logprior_net) + res = np.array([[res['_gradient_logpdf_' + p.name] for p in self.priors]]) + return res + + def _prior_density(self, x): + return np.exp(self._logprior_density(x)) + + def _neg_logprior_density(self, x): + return -1 * self._logprior_density(x) + + def plot(self): + if len(self.model.bounds) == 1: + mn = self.model.bounds[0][0] + mx = self.model.bounds[0][1] + dx = (mx - mn) / 200.0 + x = np.arange(mn, mx, dx) + pd = np.zeros(len(x)) + for i in range(len(x)): + pd[i] = self.pdf([x[i]]) + plt.figure() + plt.plot(x, pd) + plt.xlim(mn, mx) + plt.ylim(0.0, max(pd)*1.05) + plt.show() + + elif len(self.model.bounds) == 2: + x, y = np.meshgrid(np.linspace(*self.model.bounds[0]), np.linspace(*self.model.bounds[1])) + z = (np.vectorize(lambda a,b: self.pdf(np.array([a, b]))))(x, y) + plt.contour(x, y, z) + plt.show() + + else: + raise NotImplementedError("Currently unsupported for dim > 2") diff --git a/elfi/methods/results.py b/elfi/methods/results.py index 3d680e1e..19ae53ae 100644 --- a/elfi/methods/results.py +++ b/elfi/methods/results.py @@ -8,7 +8,6 @@ from matplotlib import pyplot as plt import elfi.visualization.visualization as vis -from elfi.methods.bo.utils import stochastic_optimization logger = logging.getLogger(__name__) @@ -271,205 +270,3 @@ def __init__(self, method_name, chains, parameter_names, warmup, **kwargs): def plot_traces(self, selector=None, axes=None, **kwargs): return vis.plot_traces(self, selector, axes, **kwargs) - - -class BolfiPosterior(object): - """ - Container for the approximate posterior in the BOLFI framework, where the likelihood - is defined as - - L \propto F((h - \mu) / \sigma) - - where F is the cdf of N(0,1), h is a threshold, and \mu and \sigma are the mean and (noisy) - standard deviation of the Gaussian process. - - Note that when using a log discrepancy, h should become log(h). - - References - ---------- - Gutmann M U, Corander J (2016). Bayesian Optimization for Likelihood-Free Inference - of Simulator-Based Statistical Models. JMLR 17(125):1−47, 2016. - http://jmlr.org/papers/v17/15-017.html - - Parameters - ---------- - model : object - Instance of the surrogate model, e.g. elfi.bo.gpy_regression.GPyRegression. - threshold : float, optional - The threshold value used in the calculation of the posterior, see the BOLFI paper for details. - By default, the minimum value of discrepancy estimate mean is used. - priors : list of elfi.Priors, optional - By default uniform distribution within model bounds. - max_opt_iters : int, optional - Maximum number of iterations performed in internal optimization. - """ - - def __init__(self, model, threshold=None, priors=None, max_opt_iters=10000): - super(BolfiPosterior, self).__init__() - self.threshold = threshold - self.model = model - if self.threshold is None: - minloc, minval = stochastic_optimization(self.model.predict_mean, self.model.bounds, max_opt_iters) - self.threshold = minval - logger.info("Using minimum value of discrepancy estimate mean (%.4f) as threshold" % (self.threshold)) - self.priors = priors or [None] * model.input_dim - self.max_opt_iters = max_opt_iters - - @property - def ML(self): - """ - Maximum likelihood (ML) approximation. - - Returns - ------- - tuple - Maximum likelihood parameter values and the corresponding value of neg_unnormalized_loglikelihood. - """ - x, lh_x = stochastic_optimization(self._neg_unnormalized_loglikelihood, - self.model.bounds, self.max_opt_iters) - return x, lh_x - - @property - def MAP(self): - """ - Maximum a posteriori (MAP) approximation. - - Returns - ------- - tuple - Maximum a posteriori parameter values and the corresponding value of neg_unnormalized_logposterior. - """ - x, post_x = stochastic_optimization(self._neg_unnormalized_logposterior, - self.model.bounds, self.max_opt_iters) - return x, post_x - - def logpdf(self, x): - """ - Returns the unnormalized log-posterior pdf at x. - - Parameters - ---------- - x : np.array - - Returns - ------- - float - """ - if not self._within_bounds(x): - return -np.inf - return self._unnormalized_loglikelihood(x) + self._logprior_density(x) - - def pdf(self, x): - """ - Returns the unnormalized posterior pdf at x. - - Parameters - ---------- - x : np.array - - Returns - ------- - float - """ - return np.exp(self.logpdf(x)) - - def grad_logpdf(self, x): - """ - Returns the gradient of the unnormalized log-posterior pdf at x. - - Parameters - ---------- - x : np.array - - Returns - ------- - np.array - """ - grad = self._grad_unnormalized_loglikelihood(x) + self._grad_logprior_density(x) - return grad[0] - - def __getitem__(self, idx): - return tuple([[v]*len(idx) for v in self.MAP]) - - def _unnormalized_loglikelihood(self, x): - mean, var = self.model.predict(x) - if mean is None or var is None: - raise ValueError("Unable to evaluate model at %s" % (x)) - return sp.stats.norm.logcdf(self.threshold, mean, np.sqrt(var)) - - def _grad_unnormalized_loglikelihood(self, x): - mean, var = self.model.predict(x) - if mean is None or var is None: - raise ValueError("Unable to evaluate model at %s" % (x)) - std = np.sqrt(var) - - grad_mean, grad_var = self.model.predictive_gradients(x) - grad_mean = grad_mean[:, :, 0] # assume 1D output - - factor = -grad_mean * std - (self.threshold - mean) * 0.5 * grad_var / std - factor = factor / var - term = (self.threshold - mean) / std - pdf = sp.stats.norm.pdf(term) - cdf = sp.stats.norm.cdf(term) - - return factor * pdf / cdf - - def _unnormalized_likelihood(self, x): - return np.exp(self._unnormalized_loglikelihood(x)) - - def _neg_unnormalized_loglikelihood(self, x): - return -1 * self._unnormalized_loglikelihood(x) - - def _neg_unnormalized_logposterior(self, x): - return -1 * self.logpdf(x) - - def _logprior_density(self, x): - logprior_density = 0.0 - for xv, prior in zip(x, self.priors): - if prior is not None: - logprior_density += prior.logpdf(xv) - return logprior_density - - def _within_bounds(self, x): - x = x.reshape((-1, self.model.input_dim)) - for ii in range(self.model.input_dim): - if np.any(x[:, ii] < self.model.bounds[ii][0]) or np.any(x[:, ii] > self.model.bounds[ii][1]): - return False - return True - - def _grad_logprior_density(self, x): - grad_logprior_density = np.zeros(x.shape) - for ii, prior in enumerate(self.priors): - if prior is not None: - grad_logprior_density[ii] = prior.grad_logpdf(x[ii]) - return grad_logprior_density - - def _prior_density(self, x): - return np.exp(self._logprior_density(x)) - - def _neg_logprior_density(self, x): - return -1 * self._logprior_density(x) - - def plot(self): - if len(self.model.bounds) == 1: - mn = self.model.bounds[0][0] - mx = self.model.bounds[0][1] - dx = (mx - mn) / 200.0 - x = np.arange(mn, mx, dx) - pd = np.zeros(len(x)) - for i in range(len(x)): - pd[i] = self.pdf([x[i]]) - plt.figure() - plt.plot(x, pd) - plt.xlim(mn, mx) - plt.ylim(0.0, max(pd)*1.05) - plt.show() - - elif len(self.model.bounds) == 2: - x, y = np.meshgrid(np.linspace(*self.model.bounds[0]), np.linspace(*self.model.bounds[1])) - z = (np.vectorize(lambda a,b: self.pdf(np.array([a, b]))))(x, y) - plt.contour(x, y, z) - plt.show() - - else: - raise NotImplementedError("Currently not supported for dim > 2") \ No newline at end of file diff --git a/elfi/model/elfi_model.py b/elfi/model/elfi_model.py index 1c2dd53e..f06ad150 100644 --- a/elfi/model/elfi_model.py +++ b/elfi/model/elfi_model.py @@ -170,7 +170,7 @@ def __init__(self, seed=None, batch_size=None, observed=None, pool=None): # Extract the seed from numpy RandomState. Alternative would be to use # os.urandom(4) casted as int. - self.seed = seed or np.random.RandomState().get_state()[1][0] + self.seed = np.random.RandomState().get_state()[1][0] if seed is None else seed self.batch_size = batch_size or 1 self.observed = observed or {} diff --git a/elfi/model/extensions.py b/elfi/model/extensions.py index 97e36a49..03d08caf 100644 --- a/elfi/model/extensions.py +++ b/elfi/model/extensions.py @@ -1,10 +1,17 @@ +import numpy as np + class ScipyLikeDistribution: """Abstract class for an ELFI compatible random distribution. You can implement this as having all methods as classmethods or making an instance. Hence the signatures include this, instead of self or cls. - - Note that the class signature is a subset of that of `scipy.rv_continuous` + + Note that the class signature is a subset of that of `scipy.rv_continuous`. + + Additionally, methods like BOLFI require information about the gradient of logpdf. + You can implement this as a classmethod `gradient_logpdf` with the same call signature + as `logpdf`, and return type of np.array. If this is unimplemented, ELFI will + approximate it numerically. """ def __init__(self, name=None): @@ -16,6 +23,7 @@ def __init__(self, name=None): """ self._name = name or self.__class__.__name__ + @classmethod def rvs(this, *params, size=1, random_state): """Random variates @@ -33,6 +41,7 @@ def rvs(this, *params, size=1, random_state): """ raise NotImplementedError + @classmethod def pdf(this, x, *params, **kwargs): """Probability density function at x @@ -50,23 +59,30 @@ def pdf(this, x, *params, **kwargs): """ raise NotImplementedError + @classmethod def logpdf(this, x, *params, **kwargs): """Log of the probability density function at x. Parameters ---------- x : array_like - points where to evaluate the pdf + points where to evaluate the logpdf param1, param2, ... : array_like parameters of the model kwargs Returns ------- - pdf : ndarray + logpdf : ndarray Log of the probability density function evaluated at x """ - raise NotImplementedError + p = this.pdf(x, *params, **kwargs) + + with np.warnings.catch_warnings(): + np.warnings.filterwarnings('ignore') + ans = np.log(p) + + return ans @property def name(this): diff --git a/requirements.txt b/requirements.txt index ae53a2e3..eb5de10a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,8 @@ -numpy>=1.8 -scipy>=0.16.1 +numpy>=1.12.1 +scipy>=0.19 matplotlib>=1.1 GPy>=1.0.9 networkX>=1.11 ipyparallel>=6 toolz>=0.8 +numdifftools>=0.9.20 diff --git a/scripts/MA2_run.py b/scripts/MA2_run.py index 66f0d8ed..a55c0a56 100644 --- a/scripts/MA2_run.py +++ b/scripts/MA2_run.py @@ -15,5 +15,5 @@ result.summary # save a figure of results -result.plot_pairs() -plt.savefig('ma2.png') +# result.plot_pairs() +# plt.savefig('ma2.png') diff --git a/tests/conftest.py b/tests/conftest.py index 142055d3..c9556c20 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,6 @@ import logging import time +import os import numpy as np import pytest @@ -105,3 +106,8 @@ def sleep_model(request): m.observed['slept'] = ub_sec/2 return m + +@pytest.fixture() +def skip_travis(): + if "TRAVIS" in os.environ and os.environ['TRAVIS'] == "true": + pytest.skip("Skipping this test in Travis CI due to very slow run-time. Tested locally!") diff --git a/tests/functional/test_inference.py b/tests/functional/test_inference.py index 15d0056f..6b09cd1a 100644 --- a/tests/functional/test_inference.py +++ b/tests/functional/test_inference.py @@ -20,7 +20,6 @@ """ - def setup_ma2_with_informative_data(): true_params = OrderedDict([('t1', .6), ('t2', .2)]) n_obs = 100 @@ -95,6 +94,7 @@ def test_smc(): assert res.populations[-1].n_batches < 6 +@pytest.mark.usefixtures('skip_travis') # very, very slow in Travis, but ok locally @slow @pytest.mark.usefixtures('with_all_clients') def test_BOLFI(): @@ -105,9 +105,10 @@ def test_BOLFI(): log_d = NodeReference(m['d'], state=dict(_operation=np.log), model=m, name='log_d') bolfi = elfi.BOLFI(log_d, initial_evidence=20, update_interval=10, batch_size=5, - bounds=[(-2,2)]*len(m.parameters)) - res = bolfi.infer(300) - assert bolfi.target_model.n_evidence == 300 + bounds=[(-2,2), (-1, 1)]) + n = 300 + res = bolfi.infer(n) + assert bolfi.target_model.n_evidence == n acq_x = bolfi.target_model._gp.X # check_inference_with_informative_data(res, 1, true_params, error_bound=.2) @@ -115,24 +116,23 @@ def test_BOLFI(): assert np.abs(res['samples']['t2'] - true_params['t2']) < 0.2 # Test that you can continue the inference where we left off - res = bolfi.infer(310) - assert bolfi.target_model.n_evidence == 310 - assert np.array_equal(bolfi.target_model._gp.X[:300,:], acq_x) + res = bolfi.infer(n+10) + assert bolfi.target_model.n_evidence == n+10 + assert np.array_equal(bolfi.target_model._gp.X[:n,:], acq_x) post = bolfi.infer_posterior() - post_ml, _ = post.ML - post_map, _ = post.MAP + post_ml = post.ML + post_map = post.MAP vals_ml = dict(t1=np.array([post_ml[0]]), t2=np.array([post_ml[1]])) check_inference_with_informative_data(vals_ml, 1, true_params, error_bound=.2) vals_map = dict(t1=np.array([post_map[0]]), t2=np.array([post_map[1]])) check_inference_with_informative_data(vals_map, 1, true_params, error_bound=.2) - # Commented out because for some reason, this is very, very slow in Travis - # n_samples = 100 - # n_chains = 4 - # res_sampling = bolfi.sample(n_samples, n_chains=n_chains) - # check_inference_with_informative_data(res_sampling.samples, n_samples//2*n_chains, true_params, error_bound=.2) + n_samples = 400 + n_chains = 4 + res_sampling = bolfi.sample(n_samples, n_chains=n_chains) + check_inference_with_informative_data(res_sampling.samples, n_samples//2*n_chains, true_params, error_bound=.2) # check the cached predictions for RBF x = np.random.random((1, len(true_params))) @@ -147,3 +147,9 @@ def test_BOLFI(): grad_cached_mu, grad_cached_var = bolfi.target_model.predictive_gradients(x) assert(np.allclose(grad_mu, grad_cached_mu)) assert(np.allclose(grad_var, grad_cached_var)) + + # test calculation of prior logpdfs + true_logpdf_prior = ma2.CustomPrior1.logpdf(x[0, 0], 2) + true_logpdf_prior += ma2.CustomPrior2.logpdf(x[0, 1], x[0, 0,], 1) + + assert np.isclose(true_logpdf_prior, post._logprior_density(x[0, :])) diff --git a/tests/unit/test_examples.py b/tests/unit/test_examples.py index 335b2d9e..2f761db4 100644 --- a/tests/unit/test_examples.py +++ b/tests/unit/test_examples.py @@ -9,8 +9,10 @@ def test_bdm(recwarn): """Currently only works in unix-like systems and with a cloned repository""" cpp_path = ee.bdm.get_sources_path() + do_cleanup = False if not os.path.isfile(cpp_path + '/bdm'): os.system('make -C {}'.format(cpp_path)) + do_cleanup = True assert os.path.isfile(cpp_path + '/bdm') @@ -31,6 +33,8 @@ def test_bdm(recwarn): # TODO: test the correctness of the result os.system('rm ./bdm') + if do_cleanup: + os.system('rm {}/bdm'.format(cpp_path)) def test_Gauss(): diff --git a/tests/unit/test_methods.py b/tests/unit/test_methods.py index 268a46fb..afbaab54 100644 --- a/tests/unit/test_methods.py +++ b/tests/unit/test_methods.py @@ -25,3 +25,47 @@ def test_smc_prior_use(ma2): # Test that the density is uniform assert np.allclose(dens, dens[0]) + +# very superficial test to compensate for test_inference.test_BOLFI not being run on Travis +@pytest.mark.usefixtures('with_all_clients') +def test_BOLFI_short(ma2): + + # Log discrepancy tends to work better + log_d = elfi.Operation(np.log, ma2['d']) + + bolfi = elfi.BOLFI(log_d, initial_evidence=10, update_interval=10, batch_size=5, + bounds=[(-2,2), (-1, 1)]) + n = 20 + res = bolfi.infer(n) + assert bolfi.target_model.n_evidence == n + acq_x = bolfi.target_model._gp.X + + # Test that you can continue the inference where we left off + res = bolfi.infer(n+5) + assert bolfi.target_model.n_evidence == n+5 + assert np.array_equal(bolfi.target_model._gp.X[:n,:], acq_x) + + post = bolfi.infer_posterior() + + post_ml = post.ML + post_map = post.MAP + + n_samples = 10 + n_chains = 2 + res_sampling = bolfi.sample(n_samples, n_chains=n_chains) + assert len(res_sampling.samples_list) == 2 + assert len(res_sampling.samples_list[0]) == n_samples//2 * n_chains + + # check the cached predictions for RBF + x = np.random.random((1, 2)) + bolfi.target_model.is_sampling = True + + pred_mu, pred_var = bolfi.target_model._gp.predict(x) + pred_cached_mu, pred_cached_var = bolfi.target_model.predict(x) + assert(np.allclose(pred_mu, pred_cached_mu)) + assert(np.allclose(pred_var, pred_cached_var)) + + grad_mu, grad_var = bolfi.target_model._gp.predictive_gradients(x) + grad_cached_mu, grad_cached_var = bolfi.target_model.predictive_gradients(x) + assert(np.allclose(grad_mu, grad_cached_mu)) + assert(np.allclose(grad_var, grad_cached_var)) diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py index 0c1f92d0..e3c8e76e 100644 --- a/tests/unit/test_utils.py +++ b/tests/unit/test_utils.py @@ -1,8 +1,8 @@ import numpy as np import scipy.stats as ss -from elfi.methods.bo.utils import stochastic_optimization from elfi.methods.utils import weighted_var, GMDistribution, normalize_weights +from elfi.methods.bo.utils import stochastic_optimization, minimize, numerical_gradient_logpdf def test_stochastic_optimization(): @@ -15,6 +15,26 @@ def test_stochastic_optimization(): assert abs(val - 0.0) < 1e-5 +def test_minimize(): + fun = lambda x : x[0]**2 + (x[1]-1)**4 + grad = lambda x : np.array([2*x[0], 4*(x[1]-1)**3]) + bounds = ((-2, 2), (-2, 3)) + priors = [None, None] + loc, val = minimize(fun, grad, bounds, priors) + assert np.isclose(val, 0, atol=0.01) + assert np.allclose(loc, np.array([0, 1]), atol=0.02) + + +def test_numerical_grad_logpdf(): + dist = ss.norm + loc = 2.2 + scale = 1.1 + x = np.random.rand() + grad_logpdf = -(x-loc)/scale**2 + num_grad = numerical_gradient_logpdf(x, loc, scale, distribution=dist) + assert np.isclose(grad_logpdf, num_grad, atol=0.01) + + def test_weighted_var(): # 1d case std = .3 @@ -61,4 +81,3 @@ def test_rvs(self): # Test that the mean of the second mode is correct assert np.abs(np.mean(rvs[:,1]) + 3) < .1 - From 418831c50506840b10bce58960b1ffd75080381e Mon Sep 17 00:00:00 2001 From: Jarno Lintusaari Date: Mon, 19 Jun 2017 14:43:17 +0300 Subject: [PATCH 11/38] Model augmenter (#171) * Refactoring * Dimension checks and fixes to distributions * Fixing prior pdf and logpdf dimensions * Distribution dimension fixes * Faster numerical gradient implementation * Dimensions fixed in BolfiPosterior * Few more fixes * Refactored the gradient and other smaller changes * Review changes --- elfi/examples/ma2.py | 7 +- elfi/loader.py | 8 +- elfi/methods/bo/acquisition.py | 13 +- elfi/methods/bo/gpy_regression.py | 9 +- elfi/methods/bo/utils.py | 121 +++------------ elfi/methods/methods.py | 120 ++++++++------- elfi/methods/posteriors.py | 222 ++++++++++------------------ elfi/methods/utils.py | 159 +++++++++++++++++++- elfi/model/augmenter.py | 107 ++++++++++++++ elfi/model/elfi_model.py | 17 +-- elfi/model/extensions.py | 3 +- elfi/{ => model}/graphical_model.py | 0 elfi/utils.py | 2 +- requirements.txt | 1 - tests/conftest.py | 86 ++++++++++- tests/functional/test_inference.py | 19 ++- tests/unit/test_methods.py | 10 +- tests/unit/test_results.py | 5 + tests/unit/test_utils.py | 75 ++++++++-- 19 files changed, 619 insertions(+), 365 deletions(-) create mode 100644 elfi/model/augmenter.py rename elfi/{ => model}/graphical_model.py (100%) diff --git a/elfi/examples/ma2.py b/elfi/examples/ma2.py index 37ab1ac6..7c04943b 100644 --- a/elfi/examples/ma2.py +++ b/elfi/examples/ma2.py @@ -1,6 +1,9 @@ from functools import partial +import warnings + import numpy as np import scipy.stats as ss + import elfi @@ -114,7 +117,5 @@ def rvs(cls, t1, a, size=1, random_state=None): def pdf(cls, x, t1, a): locs = np.maximum(-a - t1, -a + t1) scales = a - locs - p = ss.uniform.pdf(x, loc=locs, scale=scales) - # set values outside of [-a, a] to zero - p = np.where(scales>0., p, 0.) + p = (x >= locs) * (x <= locs + scales) * 1/np.where(scales>0, scales, 1) return p diff --git a/elfi/loader.py b/elfi/loader.py index 8bee7d44..990b57a4 100644 --- a/elfi/loader.py +++ b/elfi/loader.py @@ -98,19 +98,19 @@ class RandomStateLoader(Loader): def load(cls, context, compiled_net, batch_index): key = 'output' seed = context.seed - if seed is False: + if seed is 'global': # Get the random_state of the respective worker by delaying the evaluation random_state = get_np_random key = 'operation' - elif isinstance(seed, (int, np.uint32)): + elif isinstance(seed, (int, np.int32, np.uint32)): random_state = np.random.RandomState(context.seed) else: raise ValueError("Seed of type {} is not supported".format(seed)) # Jump (or scramble) the state based on batch_index to create parallel separate # pseudo random sequences - if seed is not False: - # TODO: In the future, allow use of https://pypi.python.org/pypi/randomstate ? + if seed is not 'global': + # TODO: In the future, we could use https://pypi.python.org/pypi/randomstate to enable jumps? random_state = np.random.RandomState(get_sub_seed(random_state, batch_index)) _random_node = '_random_state' diff --git a/elfi/methods/bo/acquisition.py b/elfi/methods/bo/acquisition.py index ba8d1f3f..53dee809 100644 --- a/elfi/methods/bo/acquisition.py +++ b/elfi/methods/bo/acquisition.py @@ -23,7 +23,7 @@ class AcquisitionBase: bounds : tuple of length 'input_dim' of tuples (min, max) and methods evaluate(x) : function that returns model (mean, var, std) - priors : list of elfi.Priors, optional + prior By default uniform distribution within model bounds. n_inits : int, optional Number of initialization points in internal optimization. @@ -33,16 +33,12 @@ class AcquisitionBase: Covariance of the added noise. If float, multiplied by identity matrix. seed : int """ - def __init__(self, model, priors=None, n_inits=10, max_opt_iters=1000, noise_cov=0., seed=0): + def __init__(self, model, prior=None, n_inits=10, max_opt_iters=1000, noise_cov=0., seed=0): self.model = model self.n_inits = n_inits self.max_opt_iters = int(max_opt_iters) - # TODO: change input to more generic get_initial_points method - if priors is None: - self.priors = [None] * model.input_dim - else: - self.priors = priors + self.prior = prior if isinstance(noise_cov, (float, int)): noise_cov = np.eye(self.model.input_dim) * noise_cov @@ -97,7 +93,7 @@ def acquire(self, n_values, pending_locations=None, t=None): obj = lambda x: self.evaluate(x, t) grad_obj = lambda x: self.evaluate_grad(x, t) - minloc, minval = minimize(obj, grad_obj, self.model.bounds, self.priors, self.n_inits, self.max_opt_iters) + minloc, minval = minimize(obj, grad_obj, self.model.bounds, self.prior, self.n_inits, self.max_opt_iters) x = np.tile(minloc, (n_values, 1)) # add some noise for more efficient exploration @@ -177,7 +173,6 @@ def evaluate_grad(self, x, t=None): """ mean, var = self.model.predict(x, noiseless=True) grad_mean, grad_var = self.model.predictive_gradients(x) - grad_mean = grad_mean[:, :, 0] # assume 1D output return grad_mean - 0.5 * grad_var * np.sqrt(self._beta(t) / var) diff --git a/elfi/methods/bo/gpy_regression.py b/elfi/methods/bo/gpy_regression.py index 042e67be..51c81192 100644 --- a/elfi/methods/bo/gpy_regression.py +++ b/elfi/methods/bo/gpy_regression.py @@ -174,15 +174,16 @@ def predictive_gradients(self, x): v = np.linalg.solve(self._rbf_woodbury_chol, kx.T + self._rbf_bias) dvdx = np.linalg.solve(self._rbf_woodbury_chol, dkdx) grad_var = -2. * dvdx.T.dot(v).T + else: + grad_mu, grad_var = self._gp.predictive_gradients(x) + grad_mu = grad_mu[:, :, 0] # Assume 1D output (distance in ABC) - return grad_mu[:, :, None], grad_var - - return self._gp.predictive_gradients(x) + return grad_mu, grad_var def predictive_gradient_mean(self, x): """Return the gradient of the GP model mean at x. """ - return self.predictive_gradients(x)[0][:, :, 0] + return self.predictive_gradients(x)[0] def _init_gp(self, x, y): self._kernel_is_default = False diff --git a/elfi/methods/bo/utils.py b/elfi/methods/bo/utils.py index c5388b19..4b16cf47 100644 --- a/elfi/methods/bo/utils.py +++ b/elfi/methods/bo/utils.py @@ -1,59 +1,8 @@ import numpy as np -import numdifftools from scipy.optimize import differential_evolution, fmin_l_bfgs_b -def approx_second_partial_derivative(fun, x0, dim, h, bounds): - """ - Approximates the second derivative of function 'fun' at 'x0' - in dimension 'dim'. If sampling location is near the bounds, - uses a symmetric approximation. - """ - val = fun(x0) - d = np.zeros(len(x0)) - d[dim] = 1.0 - if (x0 + h*d)[dim] > bounds[dim][1]: - # At upper edge, using symmetric approximation - val_m = fun(x0 - h*d) - val_p = val_m - elif (x0 - h*d)[dim] < bounds[dim][0]: - # At upper edge, using symmetric approximation - val_p = fun(x0 + h*d) - val_m = val_p - else: - val_p = fun(x0 + h*d) - val_m = fun(x0 - h*d) - return (val_p - 2*val + val_m) / (h ** 2) - - -def sum_of_rbf_kernels(point, kern_centers, kern_ampl, kern_scale): - """ - Calculates the sum of kernel weights at 'point' given that - there is one RBF kernel at each 'kern_center' and they - all have same amplitudes and scales. - - type(point) = np.array_1d - type(kern_certers) = np.array_2d (centers on rows) - """ - if kern_scale <= 0: - raise ValueError("RBF kernel scale must be positive" - "(was: %.2f)" % (kern_scale)) - if kern_ampl < 0: - raise ValueError("RBF kernel amplitude must not be negative" - "(was: %.2f)" % (kern_ampl)) - if kern_ampl == 0: - return 0 - if len(kern_centers) == 0: - return 0 - if kern_centers.shape[1] != point.shape[0]: - raise ValueError("kern_centers shape must match point shape") - ret = 0 - for i in range(kern_centers.shape[0]): - sqdist = sum((kern_centers[i,:] - point) ** 2) - ret += kern_ampl * np.exp(-sqdist / kern_scale) - return ret - - +# TODO: remove or combine to minimize def stochastic_optimization(fun, bounds, maxiter=1000, polish=True, seed=0): """ Called to find the minimum of function 'fun' in 'maxiter' iterations """ result = differential_evolution(func=fun, bounds=bounds, maxiter=maxiter, @@ -61,7 +10,8 @@ def stochastic_optimization(fun, bounds, maxiter=1000, polish=True, seed=0): return result.x, result.fun -def minimize(fun, grad, bounds, priors, n_inits=10, maxiter=1000, random_state=None): +# TODO: allow argument for specifying the optimization algorithm +def minimize(fun, grad, bounds, prior=None, n_start_points=10, maxiter=1000, random_state=None): """ Called to find the minimum of function 'fun'. Parameters @@ -72,9 +22,9 @@ def minimize(fun, grad, bounds, priors, n_inits=10, maxiter=1000, random_state=N Gradient of fun. bounds : list of tuples Bounds for each parameter. - priors : list of elfi.Priors, or list of Nones - Used for sampling initialization points. If Nones, sample uniformly. - n_inits : int, optional + prior : scipy-like distribution object + Used for sampling initialization points. If None, samples uniformly. + n_start_points : int, optional Number of initialization points. maxiter : int, optional Maximum number of iterations. @@ -85,63 +35,30 @@ def minimize(fun, grad, bounds, priors, n_inits=10, maxiter=1000, random_state=N ------- tuple of the found coordinates of minimum and the corresponding value. """ - inits = np.empty((n_inits, len(priors))) + ndim = len(bounds) + start_points = np.empty((n_start_points, ndim)) - # TODO: change input to more generic get_initial_points method - if priors[0] is None: + # TODO: use same prior as the bo.acquisition.UniformAcquisition + if prior is None: # Sample initial points uniformly within bounds random_state = random_state or np.random.RandomState() - for ii in range(len(priors)): - inits[:, ii] = random_state.uniform(*bounds[ii], n_inits) - + for i in range(ndim): + start_points[:, i] = random_state.uniform(*bounds[i], n_start_points) else: - # Sample priors for initialization points - prior_names = [p.name for p in priors] - inits_dict = priors[0].model.generate(n_inits, outputs=prior_names) - for ii, n in enumerate(prior_names): - inits[:, ii] = inits_dict[n] - inits[:, ii] = np.clip(inits[:, ii], *bounds[ii]) + start_points = prior.rvs(n_start_points) + for i in range(ndim): + start_points[:, i] = np.clip(start_points[:, i], *bounds[i]) locs = [] - vals = np.empty(n_inits) + vals = np.empty(n_start_points) # Run optimization from each initialization point - for ii in range(n_inits): - result = fmin_l_bfgs_b(fun, inits[ii, :], fprime=grad, bounds=bounds, maxiter=maxiter) + for i in range(n_start_points): + result = fmin_l_bfgs_b(fun, start_points[i, :], fprime=grad, bounds=bounds, maxiter=maxiter) locs.append(result[0]) - vals[ii] = result[1] + vals[i] = result[1] # Return the optimal case ind_min = np.argmin(vals) return locs[ind_min], vals[ind_min] - -def numerical_gradient_logpdf(x, *params, distribution=None, **kwargs): - """Gradient of the log of the probability density function at x. - - Approximated numerically. - - Parameters - ---------- - x : array_like - points where to evaluate the gradient - param1, param2, ... : array_like - parameters of the model - distribution : ScipyLikeDistribution or a distribution from Scipy - - Returns - ------- - grad_logpdf : ndarray - Gradient of the log of the probability density function evaluated at x - """ - - # due to the common scenario of logpdf(x) = -inf, multiple confusing warnings could be generated - with np.warnings.catch_warnings(): - np.warnings.filterwarnings('ignore') - if np.isinf(distribution.logpdf(x, *params, **kwargs)): - grad = np.zeros_like(x) # logpdf = -inf => grad = 0 - else: - grad = numdifftools.Gradient(distribution.logpdf)(x, *params, **kwargs) - grad = np.where(np.isnan(grad), 0, grad) - - return grad diff --git a/elfi/methods/methods.py b/elfi/methods/methods.py index e65e5d4e..51d6ff04 100644 --- a/elfi/methods/methods.py +++ b/elfi/methods/methods.py @@ -1,17 +1,15 @@ import logging from collections import OrderedDict -from functools import reduce, partial from math import ceil -from operator import mul import matplotlib.pyplot as plt import numpy as np -from toolz.functoolz import compose import elfi.client import elfi.visualization.visualization as vis import elfi.visualization.interactive as visin import elfi.methods.mcmc as mcmc +import elfi.model.augmenter as augmenter from elfi.loader import get_sub_seed from elfi.methods.bo.acquisition import LCBSC @@ -19,9 +17,8 @@ from elfi.methods.bo.utils import stochastic_optimization from elfi.methods.results import Result, ResultSMC, ResultBOLFI from elfi.methods.posteriors import BolfiPosterior -from elfi.methods.utils import GMDistribution, weighted_var -from elfi.model.elfi_model import ComputationContext, NodeReference, Operation, ElfiModel -from elfi.utils import args_to_tuple +from elfi.methods.utils import GMDistribution, weighted_var, ModelPrior +from elfi.model.elfi_model import ComputationContext, NodeReference, ElfiModel logger = logging.getLogger(__name__) @@ -115,6 +112,7 @@ def __init__(model, discrepancy, ...): """ +# TODO: prevent loading from OutputPool in SMC and BO # TODO: use only either n_batches or n_sim in state dict # TODO: plan how continuing the inference is standardized @@ -131,12 +129,13 @@ def __init__(self, model, outputs, batch_size=1000, seed=None, pool=None, Parameters ---------- - model : ElfiModel or NodeReference + model : ElfiModel + Model to perform the inference with. outputs : list Contains the node names for which the algorithm needs to receive the outputs in every batch. batch_size : int - seed : int + seed : int, optional Seed for the data generation from the ElfiModel pool : OutputPool OutputPool both stores and provides precomputed values for batches. @@ -146,7 +145,6 @@ def __init__(self, model, outputs, batch_size=1000, seed=None, pool=None, """ - model = model.model if isinstance(model, NodeReference) else model if not model.parameters: raise ValueError('Model {} defines no parameters'.format(model)) @@ -156,8 +154,8 @@ def __init__(self, model, outputs, batch_size=1000, seed=None, pool=None, # Prepare the computation_context context = ComputationContext( - seed=seed, batch_size=batch_size, + seed=seed, observed=model.computation_context.observed, pool=pool ) @@ -216,7 +214,7 @@ def extract_result(self): """ raise NotImplementedError - def _update(self, batch, batch_index): + def update(self, batch, batch_index): """ELFI calls this method when a new batch has been computed and the state of the inference should be updated with it. @@ -233,7 +231,7 @@ def _update(self, batch, batch_index): """ raise NotImplementedError - def _prepare_new_batch(self, batch_index): + def prepare_new_batch(self, batch_index): """ELFI calls this method before submitting a new batch with an increasing index `batch_index`. This is an optional method to override. Use this if you have a need do do preparations, e.g. in Bayesian optimization algorithm, the next acquisition @@ -254,6 +252,20 @@ def _prepare_new_batch(self, batch_index): """ pass + def _init_model(self, model): + """Initialize the model. + + If your algorithm needs to modify the model, you may do so here. ELFI will call + this method before compiling the model. + + Parameters + ---------- + model : elfi.ElfiModel + A copy of the original model. + + """ + return model + def plot_state(self, **kwargs): """ @@ -323,12 +335,12 @@ def iterate(self): # Submit new batches if allowed while self._allow_submit: batch_index = self.batches.next_index - batch = self._prepare_new_batch(batch_index) + batch = self.prepare_new_batch(batch_index) self.batches.submit(batch) # Handle the next batch in succession batch, batch_index = self.batches.wait_next() - self._update(batch, batch_index) + self.update(batch, batch_index) @property def finished(self): @@ -393,11 +405,21 @@ def _resolve_model(model, target, default_reference_class=NodeReference): return model, target.name @staticmethod - def _ensure_outputs(outputs, required_outputs): - outputs = outputs or [] - for out in required_outputs: - if out not in outputs: - outputs.append(out) + def _compose_outputs(*output_args): + outputs = [] + for arg in output_args: + if arg is None: + continue + + if isinstance(arg, str): + arg = [arg] + elif not isinstance(arg, (list, tuple)): + raise ValueError('Unknown output argument type: {}'.format(arg)) + + for output in arg: + if output not in outputs: + outputs.append(output) + return outputs @@ -443,7 +465,7 @@ def __init__(self, model, discrepancy=None, outputs=None, **kwargs): """ model, self.discrepancy = self._resolve_model(model, discrepancy) - outputs = self._ensure_outputs(outputs, model.parameters + [self.discrepancy]) + outputs = self._compose_outputs(outputs, model.parameters, self.discrepancy) super(Rejection, self).__init__(model, outputs, **kwargs) def set_objective(self, n_samples, threshold=None, quantile=None, n_sim=None): @@ -485,7 +507,7 @@ def set_objective(self, n_samples, threshold=None, quantile=None, n_sim=None): # Reset the inference self.batches.reset() - def _update(self, batch, batch_index): + def update(self, batch, batch_index): if self.state['samples'] is None: # Lazy initialization of the outputs dict self._init_samples_lazy(batch) @@ -533,13 +555,15 @@ def _init_samples_lazy(self, batch): len(batch[node]), self.batch_size)) except TypeError: - raise ValueError("Node {} output has no length. It should be equal to" + raise ValueError("Node {} output has no length. It should be equal to " "the batch size {}.".format(node, self.batch_size)) except KeyError: raise KeyError("Did not receive outputs for node {}".format(node)) # Prepare samples shape = (self.objective['n_samples'] + self.batch_size,) + batch[node].shape[1:] + # FIXME: add the correct dtype from batch. The inf initialization only for the + # distance samples[node] = np.ones(shape) * np.inf self.state['samples'] = samples @@ -601,12 +625,17 @@ def plot_state(self, **options): class SMC(Sampler): """Sequential Monte Carlo ABC sampler""" def __init__(self, model, discrepancy=None, outputs=None, **kwargs): - model, self.discrepancy = self._resolve_model(model, discrepancy) - outputs = self._ensure_outputs(outputs, model.parameters + [self.discrepancy]) - model, added_nodes = self._augment_model(model) + model, discrepancy = self._resolve_model(model, discrepancy) + + # Add the prior pdf nodes to the model + model = model.copy() + pdf = augmenter.add_pdf_nodes(model)[0] + outputs = self._compose_outputs(outputs, model.parameters, discrepancy, pdf) - super(SMC, self).__init__(model, outputs + added_nodes, **kwargs) + super(SMC, self).__init__(model, outputs, **kwargs) + self.discrepancy = discrepancy + self.prior_pdf = pdf self.state['round'] = 0 self._populations = [] self._rejection = None @@ -634,8 +663,8 @@ def extract_result(self): return result - def _update(self, batch, batch_index): - self._rejection._update(batch, batch_index) + def update(self, batch, batch_index): + self._rejection.update(batch, batch_index) if self._rejection.finished: self.batches.cancel_pending() @@ -647,7 +676,7 @@ def _update(self, batch, batch_index): self._update_state() self._update_objective() - def _prepare_new_batch(self, batch_index): + def prepare_new_batch(self, batch_index): # Use the actual prior if self.state['round'] == 0: return @@ -687,7 +716,7 @@ def _compute_weights_and_cov(self, pop): if self._populations: q_densities = GMDistribution.pdf(params, *self._gm_params) - w = samples['_prior_pdf'] / q_densities + w = samples[self.prior_pdf] / q_densities else: w = np.ones(pop.n_samples) @@ -710,20 +739,6 @@ def _update_objective(self): n_batches = sum([pop.n_batches for pop in self._populations]) self.objective['n_batches'] = n_batches + self._rejection.objective['n_batches'] - @staticmethod - def _augment_model(model): - # Add nodes to the model for computing the prior density - model = model.copy() - pdfs = [] - for p in model.parameters: - param = model[p] - pdfs.append(Operation(param.distribution.pdf, *([param] + param.parents), - model=model, name='_{}_pdf*'.format(p))) - # Multiply the individual pdfs - Operation(compose(partial(reduce, mul), args_to_tuple), *pdfs, model=model, - name='_prior_pdf') - return model, ['_prior_pdf'] - @property def _gm_params(self): pop_ = self._populations[-1] @@ -740,7 +755,7 @@ class BayesianOptimization(InferenceMethod): def __init__(self, model, target=None, outputs=None, batch_size=1, initial_evidence=None, update_interval=10, bounds=None, target_model=None, - acquisition_method=None, acq_noise_cov=1., **kwargs): + acquisition_method=None, acq_noise_cov=0, **kwargs): """ Parameters ---------- @@ -766,7 +781,8 @@ def __init__(self, model, target=None, outputs=None, batch_size=1, """ model, self.target = self._resolve_model(model, target) - outputs = self._ensure_outputs(outputs, model.parameters + [self.target]) + outputs = self._compose_outputs(outputs, model.parameters, self.target) + super(BayesianOptimization, self).\ __init__(model, outputs=outputs, batch_size=batch_size, **kwargs) @@ -793,9 +809,8 @@ def __init__(self, model, target=None, outputs=None, batch_size=1, if initial_evidence % self.batch_size != 0: raise ValueError('Initial evidence must be divisible by the batch size') - priors = [self.model[p] for p in self.parameters] self.acquisition_method = acquisition_method or \ - LCBSC(target_model, priors=priors, + LCBSC(target_model, prior=ModelPrior(self.model), noise_cov=acq_noise_cov, seed=self.seed) # TODO: move some of these to objective @@ -826,7 +841,7 @@ def extract_result(self): return dict(samples=param_hat) - def _update(self, batch, batch_index): + def update(self, batch, batch_index): """Update the GP regression model of the target node. """ self.state['pending'].pop(batch_index, None) @@ -843,7 +858,7 @@ def _update(self, batch, batch_index): self.state['n_batches'] += 1 self.state['n_sim'] += self.batch_size - def _prepare_new_batch(self, batch_index): + def prepare_new_batch(self, batch_index): if self._n_submitted_evidence < self.n_initial_evidence - self._n_precomputed: return @@ -988,13 +1003,12 @@ def infer_posterior(self, threshold=None): Returns ------- - BolfiPosterior object + posterior : elfi.methods.posteriors.BolfiPosterior """ if self.state['n_batches'] == 0: self.fit() - priors = [self.model[p] for p in self.parameters] - return BolfiPosterior(self.target_model, threshold=threshold, priors=priors) + return BolfiPosterior(self.target_model, threshold=threshold, prior=ModelPrior(self.model)) def sample(self, n_samples, warmup=None, n_chains=4, threshold=None, initials=None, diff --git a/elfi/methods/posteriors.py b/elfi/methods/posteriors.py index 74bc5418..0c9d5a8e 100644 --- a/elfi/methods/posteriors.py +++ b/elfi/methods/posteriors.py @@ -1,19 +1,17 @@ import logging import numpy as np -import scipy as sp -import matplotlib +import scipy.stats as ss import matplotlib.pyplot as plt -from functools import partial -import elfi -from elfi.methods.bo.utils import minimize, numerical_gradient_logpdf +from elfi.methods.bo.utils import minimize logger = logging.getLogger(__name__) -class BolfiPosterior(object): +# TODO: separate the likelihood to its own class +class BolfiPosterior: """ Container for the approximate posterior in the BOLFI framework, where the likelihood is defined as @@ -33,12 +31,12 @@ class BolfiPosterior(object): Parameters ---------- - model : object - Instance of the surrogate model, e.g. elfi.bo.gpy_regression.GPyRegression. + model : elfi.bo.gpy_regression.GPyRegression + Instance of the surrogate model threshold : float, optional - The threshold value used in the calculation of the posterior, see the BOLFI paper for details. - By default, the minimum value of discrepancy estimate mean is used. - priors : list of elfi.Priors, optional + The threshold value used in the calculation of the posterior, see the BOLFI paper + for details. By default, the minimum value of discrepancy estimate mean is used. + prior : ScipyLikeDistribution, optional By default uniform distribution within model bounds. n_inits : int, optional Number of initialization points in internal optimization. @@ -46,7 +44,8 @@ class BolfiPosterior(object): Maximum number of iterations performed in internal optimization. """ - def __init__(self, model, threshold=None, priors=None, n_inits=10, max_opt_iters=1000, seed=0): + def __init__(self, model, threshold=None, prior=None, n_inits=10, max_opt_iters=1000, + seed=0): super(BolfiPosterior, self).__init__() self.threshold = threshold self.model = model @@ -54,48 +53,25 @@ def __init__(self, model, threshold=None, priors=None, n_inits=10, max_opt_iters self.n_inits = n_inits self.max_opt_iters = max_opt_iters - if priors is None: - self.priors = [None] * model.input_dim - else: - self.priors = priors - self._prepare_logprior_nets() + self.prior = prior + self.dim = self.model.input_dim if self.threshold is None: - minloc, minval = minimize(self.model.predict_mean, self.model.predictive_gradient_mean, - self.model.bounds, self.priors, self.n_inits, self.max_opt_iters, + # TODO: the evidence could be used for a good guess for starting locations + minloc, minval = minimize(self.model.predict_mean, + self.model.predictive_gradient_mean, + self.model.bounds, + self.prior, + self.n_inits, + self.max_opt_iters, random_state=self.random_state) self.threshold = minval - logger.info("Using minimum value of discrepancy estimate mean (%.4f) as threshold" % (self.threshold)) - - @property - def ML(self): - """ - Maximum likelihood (ML) approximation. + logger.info("Using optimized minimum value (%.4f) of the GP discrepancy mean " + "function as a threshold" % (self.threshold)) - Returns - ------- - np.array - Maximum likelihood parameter values. - """ - x, lh_x = minimize(self._neg_unnormalized_loglikelihood, self._gradient_neg_unnormalized_loglikelihood, - self.model.bounds, self.priors, self.n_inits, self.max_opt_iters, - random_state=self.random_state) - return x - - @property - def MAP(self): - """ - Maximum a posteriori (MAP) approximation. - - Returns - ------- - np.array - Maximum a posteriori parameter values. - """ - x, post_x = minimize(self._neg_unnormalized_logposterior, self._gradient_neg_unnormalized_logposterior, - self.model.bounds, self.priors, self.n_inits, self.max_opt_iters, - random_state=self.random_state) - return x + def rvs(self, size=None, random_state=None): + raise NotImplementedError('Currently not implemented. Please use a sampler to ' + 'sample from the posterior.') def logpdf(self, x): """ @@ -109,9 +85,7 @@ def logpdf(self, x): ------- float """ - if not self._within_bounds(x): - return -np.inf - return self._unnormalized_loglikelihood(x) + self._logprior_density(x) + return self._unnormalized_loglikelihood(x) + self.prior.logpdf(x) def pdf(self, x): """ @@ -139,35 +113,69 @@ def gradient_logpdf(self, x): ------- np.array """ - grad = self._gradient_unnormalized_loglikelihood(x) + self._gradient_logprior_density(x) - return grad[0] - def __getitem__(self, idx): - return tuple([[v]*len(idx) for v in self.MAP]) + grads = self._gradient_unnormalized_loglikelihood(x) + \ + self.prior.gradient_logpdf(x) + + # nan grads are result from -inf logpdf + #return np.where(np.isnan(grads), 0, grads)[0] + return grads def _unnormalized_loglikelihood(self, x): + x = np.asanyarray(x) + ndim = x.ndim + x = x.reshape((-1, self.dim)) + + logpdf = -np.ones(len(x))*np.inf + + logi = self._within_bounds(x) + x = x[logi,:] + if len(x) == 0: + if ndim == 0 or (ndim==1 and self.dim > 1): + logpdf = logpdf[0] + return logpdf + mean, var = self.model.predict(x) - if mean is None or var is None: - raise ValueError("Unable to evaluate model at %s" % (x)) - return sp.stats.norm.logcdf(self.threshold, mean, np.sqrt(var)) + logpdf[logi] = ss.norm.logcdf(self.threshold, mean, np.sqrt(var)) + + if ndim == 0 or (ndim==1 and self.dim > 1): + logpdf = logpdf[0] + + return logpdf def _gradient_unnormalized_loglikelihood(self, x): + x = np.asanyarray(x) + ndim = x.ndim + x = x.reshape((-1, self.dim)) + + grad = np.zeros_like(x) + + logi = self._within_bounds(x) + x = x[logi,:] + if len(x) == 0: + if ndim == 0 or (ndim==1 and self.dim > 1): + grad = grad[0] + return grad + mean, var = self.model.predict(x) - if mean is None or var is None: - raise ValueError("Unable to evaluate model at %s" % (x)) std = np.sqrt(var) grad_mean, grad_var = self.model.predictive_gradients(x) - grad_mean = grad_mean[:, :, 0] # assume 1D output factor = -grad_mean * std - (self.threshold - mean) * 0.5 * grad_var / std factor = factor / var term = (self.threshold - mean) / std - pdf = sp.stats.norm.pdf(term) - cdf = sp.stats.norm.cdf(term) + pdf = ss.norm.pdf(term) + cdf = ss.norm.cdf(term) + + grad[logi, :] = factor * pdf / cdf + + if ndim == 0 or (ndim==1 and self.dim > 1): + grad = grad[0] - return factor * pdf / cdf + return grad + # TODO: check if these are used def _unnormalized_likelihood(self, x): return np.exp(self._unnormalized_loglikelihood(x)) @@ -183,85 +191,13 @@ def _neg_unnormalized_logposterior(self, x): def _gradient_neg_unnormalized_logposterior(self, x): return -1 * self.gradient_logpdf(x) - def _prepare_logprior_nets(self): - """Prepares graphs for calculating self._logprior_density and self._grad_logprior_density. - """ - self._client = elfi.clients.native.Client() # no need to parallelize here (sampling already parallel) - - # add numerical gradients, if necessary - for p in self.priors: - if not hasattr(p.distribution, 'gradient_logpdf'): - p.distribution.gradient_logpdf = partial(numerical_gradient_logpdf, distribution=p.distribution) - - # augment one model with logpdfs and another with their gradients - for target in ['logpdf', 'gradient_logpdf']: - model2 = self.priors[0].model.copy() - outputs = [] - for param in model2.parameters: - node = model2[param] - name = '_{}_{}'.format(target, param) - op = eval('node.distribution.' + target) - elfi.Operation(op, *([node] + node.parents), name=name, model=model2) - outputs.append(name) - - # compile and load the new net with data - context = model2.computation_context.copy() - context.batch_size = 1 # TODO: need more? - compiled_net = self._client.compile(model2.source_net, outputs) - loaded_net = self._client.load_data(compiled_net, context, batch_index=0) - - # remove requests for computation for priors (values assigned later) - for param in model2.parameters: - loaded_net.node[param].pop('operation') - - if target == 'logpdf': - self._logprior_net = loaded_net - else: - self._gradient_logprior_net = loaded_net - - def _logprior_density(self, x): - if self.priors[0] is None: - return 0 - - else: # need to evaluate the graph (up to priors) to account for potential hierarchies - - # load observations for priors - for i, p in enumerate(self.priors): - n = p.name - self._logprior_net.node[n]['output'] = x[i] - - # evaluate graph and return sum of logpdfs - res = self._client.compute(self._logprior_net) - return np.array([[sum([v for v in res.values()])]]) - def _within_bounds(self, x): - x = x.reshape((-1, self.model.input_dim)) - for ii in range(self.model.input_dim): - if np.any(x[:, ii] < self.model.bounds[ii][0]) or np.any(x[:, ii] > self.model.bounds[ii][1]): - return False - return True - - def _gradient_logprior_density(self, x): - if self.priors[0] is None: - return 0 - - else: # need to evaluate the graph (up to priors) to account for potential hierarchies - - # load observations for priors - for i, p in enumerate(self.priors): - n = p.name - self._gradient_logprior_net.node[n]['output'] = x[i] - - # evaluate graph and return components of individual gradients of logpdfs (assumed independent!) - res = self._client.compute(self._gradient_logprior_net) - res = np.array([[res['_gradient_logpdf_' + p.name] for p in self.priors]]) - return res - - def _prior_density(self, x): - return np.exp(self._logprior_density(x)) - - def _neg_logprior_density(self, x): - return -1 * self._logprior_density(x) + x = x.reshape((-1, self.dim)) + logical = np.ones(len(x), dtype=bool) + for i in range(self.dim): + logical *= (x[:, i] >= self.model.bounds[i][0]) + logical *= (x[:, i] <= self.model.bounds[i][1]) + return logical def plot(self): if len(self.model.bounds) == 1: diff --git a/elfi/methods/utils.py b/elfi/methods/utils.py index bda35636..02b1317f 100644 --- a/elfi/methods/utils.py +++ b/elfi/methods/utils.py @@ -3,6 +3,10 @@ import numpy as np import scipy.stats as ss +from elfi.model.elfi_model import ComputationContext +import elfi.model.augmenter as augmenter +from elfi.clients.native import Client +from elfi.utils import get_sub_seed logger = logging.getLogger(__name__) @@ -61,22 +65,34 @@ def pdf(cls, x, means, cov=1, weights=None): Parameters ---------- x : array_like - 1d or 2d array of points where to evaluate + scalar, 1d or 2d array of points where to evaluate, observations in rows means : array_like - means of the Gaussian mixture components + means of the Gaussian mixture components. It is assumed that means[0] contains + the mean of the first gaussian component. weights : array_like 1d array of weights of the gaussian mixture components cov : array_like a shared covariance matrix for the mixture components """ - x = np.atleast_1d(x) means, weights = cls._normalize_params(means, weights) + ndim = np.asanyarray(x).ndim + if means.ndim == 1: + x = np.atleast_1d(x) + if means.ndim == 2: + x = np.atleast_2d(x) + d = np.zeros(len(x)) for m, w in zip(means, weights): d += w * ss.multivariate_normal.pdf(x, mean=m, cov=cov) - return d + + # Cast to correct ndim + if ndim == 0 or (ndim==1 and means.ndim==2): + return d.squeeze() + else: + return d + @classmethod def rvs(cls, means, cov=1, weights=None, size=1, random_state=None): @@ -110,7 +126,142 @@ def rvs(cls, means, cov=1, weights=None, size=1, random_state=None): @staticmethod def _normalize_params(means, weights): means = np.atleast_1d(means) + if means.ndim > 2: + raise ValueError('means.ndim = {} but must be at most 2.'.format(means.ndim)) + if weights is None: weights = np.ones(len(means)) weights = normalize_weights(weights) return means, weights + + +def numgrad(fn, x, h=0.00001): + """Naive numeric gradient implementation for scalar valued functions. + + Parameters + ---------- + fn + x : np.ndarray + A single point in 1d vector + h + + Returns + ------- + + """ + + x = np.asanyarray(x, dtype=np.float).reshape(-1) + dim = len(x) + X = np.zeros((dim*3, dim)) + + for i in range(3): + Xi = np.tile(x, (dim, 1)) + np.fill_diagonal(Xi, Xi.diagonal() + (i-1)*h) + X[i*dim:(i+1)*dim, :] = Xi + + f = fn(X) + f = f.reshape((3, dim)) + + fgrad = np.gradient(f, h, axis=0) + + return fgrad[1, :] + + +# TODO: check that there are no latent variables in parameter parents. +# pdfs and gradients wouldn't be correct in those cases as it would require integrating out those latent +# variables. This is equivalent to that all stochastic nodes are parameters. +# TODO: needs some optimization +class ModelPrior: + """Constructs a joint prior distribution over all the parameter nodes in `ElfiModel`""" + + def __init__(self, model): + """ + + Parameters + ---------- + model : elfi.ElfiModel + """ + model = model.copy() + self.parameters = model.parameters + self.dim = len(self.parameters) + self.client = Client() + + self.context = ComputationContext() + + # Prepare nets for the pdf methods + self._pdf_node = augmenter.add_pdf_nodes(model, log=False)[0] + self._logpdf_node = augmenter.add_pdf_nodes(model, log=True)[0] + + self._rvs_net = self.client.compile(model.source_net, outputs=self.parameters) + self._pdf_net = self.client.compile(model.source_net, outputs=self._pdf_node) + self._logpdf_net = self.client.compile(model.source_net, outputs=self._logpdf_node) + + def rvs(self, size=None, random_state=None): + random_state = random_state or np.random + + self.context.batch_size = size or 1 + self.context.seed = get_sub_seed(random_state, 0) + + loaded_net = self.client.load_data(self._rvs_net, self.context, batch_index=0) + batch = self.client.compute(loaded_net) + rvs = np.column_stack([batch[p] for p in self.parameters]) + + if self.dim == 1: + rvs = rvs.reshape(size or 1) + + return rvs[0] if size is None else rvs + + def pdf(self, x): + return self._evaluate_pdf(x) + + def logpdf(self, x): + return self._evaluate_pdf(x, log=True) + + def _evaluate_pdf(self, x, log=False): + if log: + net = self._logpdf_net + node = self._logpdf_node + else: + net = self._pdf_net + node = self._pdf_node + + x = np.asanyarray(x) + ndim = x.ndim + x = x.reshape((-1, self.dim)) + batch = self._to_batch(x) + + self.context.batch_size = len(x) + loaded_net = self.client.load_data(net, self.context, batch_index=0) + + # Override + for k, v in batch.items(): loaded_net.node[k] = {'output': v} + + val = self.client.compute(loaded_net)[node] + if ndim == 0 or (ndim==1 and self.dim > 1): + val = val[0] + + return val + + def gradient_pdf(self, x): + raise NotImplementedError + + def gradient_logpdf(self, x): + x = np.asanyarray(x) + ndim = x.ndim + x = x.reshape((-1, self.dim)) + + grads = np.zeros_like(x) + + for i in range(len(grads)): + xi = x[i] + grads[i] = numgrad(self.logpdf, xi) + + grads[np.isinf(grads)] = 0 + grads[np.isnan(grads)] = 0 + + if ndim == 0 or (ndim==1 and self.dim > 1): + grads = grads[0] + return grads + + def _to_batch(self, x): + return {p: x[:, i] for i, p in enumerate(self.parameters)} diff --git a/elfi/model/augmenter.py b/elfi/model/augmenter.py new file mode 100644 index 00000000..f0cec9e8 --- /dev/null +++ b/elfi/model/augmenter.py @@ -0,0 +1,107 @@ +import functools +from functools import partial, reduce +from operator import mul, add + +from toolz.functoolz import compose + +from elfi.model.elfi_model import NodeReference, Operation +from elfi.utils import args_to_tuple + + +def add_pdf_gradient_nodes(model, log=False, nodes=None): + """Adds gradient nodes for distribution nodes to the model and returns the node names. + + By default this gives the pdfs of the generated model parameters. + + Parameters + ---------- + model : elfi.ElfiModel + log : bool, optional + Use gradient of logpdf, default False. + nodes : list, optional + List of distribution node names. Default is `model.parameters`. + + Returns + ------- + gradients : list + List of gradient node names. + + """ + + nodes = nodes or model.parameters + gradattr = 'gradient_pdf' if log is False else 'gradient_logpdf' + + grad_nodes = _add_distribution_nodes(model, nodes, gradattr) + + return [g.name for g in grad_nodes] + + +# TODO: check that there are no latent variables. See model.utils.ModelPrior +def add_pdf_nodes(model, joint=True, log=False, nodes=None): + """Adds pdf nodes for distribution nodes to the model and returns the node names. + + By default this gives the pdfs of the generated model parameters. + + Parameters + ---------- + model : elfi.ElfiModel + joint : bool, optional + If True (default) return a the joint pdf of the priors + log : bool, optional + Use logpdf, default False. + nodes : list, optional + List of distribution node names. Default is `model.parameters`. + + Returns + ------- + pdfs : list + List of node names. Either only the joint pdf node name or the separate pdf node + names depending on the `joint` argument. + + """ + nodes = nodes or model.parameters + pdfattr = 'pdf' if log is False else 'logpdf' + + pdfs = _add_distribution_nodes(model, nodes, pdfattr) + + if joint: + if log: + return [add_reduce_node(model, pdfs, add, '_joint_{}*'.format(pdfattr))] + else: + return [add_reduce_node(model, pdfs, mul, '_joint_{}*'.format(pdfattr))] + else: + return [pdf.name for pdf in pdfs] + + +def _add_distribution_nodes(model, nodes, attr): + distribution_nodes = [] + for n in nodes: + node = model[n] + op = getattr(node.distribution, attr) + distribution_nodes.append(Operation(op, *([node] + node.parents), + model=model, name='_{}_{}'.format(n, attr))) + return distribution_nodes + + +def add_reduce_node(model, nodes, reduce_operation, name): + """Reduce the output from a collection of nodes + + Parameters + ---------- + model : elfi.ElfiModel + nodes : list + Either a list of node names or a list of node reference objects + reduce_operation : callable + name : str + Name for the reduce node + + Returns + ------- + name : str + name of the new node + """ + name = '_reduce*' if name is None else name + nodes = [n if isinstance(n, NodeReference) else model[n] for n in nodes] + op = Operation(compose(partial(reduce, reduce_operation), args_to_tuple), *nodes, + model=model, name=name) + return op.name \ No newline at end of file diff --git a/elfi/model/elfi_model.py b/elfi/model/elfi_model.py index f06ad150..950382c5 100644 --- a/elfi/model/elfi_model.py +++ b/elfi/model/elfi_model.py @@ -8,12 +8,12 @@ import scipy.spatial import elfi.client -from elfi.graphical_model import GraphicalModel +from elfi.model.graphical_model import GraphicalModel from elfi.model.utils import rvs_from_distribution, distance_as_discrepancy from elfi.store import OutputPool from elfi.utils import scipy_from_str, observed_name -__all__ = ['ElfiModel', 'ComputationContext', 'NodeReference', 'RandomVariable', +__all__ = ['ElfiModel', 'ComputationContext', 'NodeReference', 'Constant', 'Operation', 'Prior', 'Simulator', 'Summary', 'Discrepancy', 'Distance', 'get_current_model', 'set_current_model'] @@ -153,16 +153,15 @@ class ComputationContext: """ - def __init__(self, seed=None, batch_size=None, observed=None, pool=None): + def __init__(self, batch_size=None, seed=None, observed=None, pool=None): """ Parameters ---------- - seed : int, False, None (default) - - When None, generates a random integer seed. - - When False, numpy's global random_state will be used in all computations. - Used for testing. batch_size : int + seed : int, None, 'global' + When None generates a random integer seed. When `'global'` uses the global numpy random state. Only + recommended for debugging observed : dict pool : elfi.OutputPool @@ -254,7 +253,7 @@ def generate(self, batch_size=1, outputs=None, with_values=None): context = self.computation_context.copy() # Use the global random_state - context.seed = False + context.seed = 'global' context.batch_size = batch_size if with_values is not None: pool = OutputPool(with_values.keys()) @@ -267,7 +266,7 @@ def generate(self, batch_size=1, outputs=None, with_values=None): return client.compute(loaded_net) def get_reference(self, name): - """Returns a new node reference object for a node in the model.""" + """Returns a new reference object for a node in the model.""" cls = self.get_node(name)['_class'] return cls.reference(name, self) diff --git a/elfi/model/extensions.py b/elfi/model/extensions.py index 03d08caf..95972dbc 100644 --- a/elfi/model/extensions.py +++ b/elfi/model/extensions.py @@ -1,13 +1,14 @@ import numpy as np +# TODO: move somewhere else? class ScipyLikeDistribution: """Abstract class for an ELFI compatible random distribution. You can implement this as having all methods as classmethods or making an instance. Hence the signatures include this, instead of self or cls. Note that the class signature is a subset of that of `scipy.rv_continuous`. - + Additionally, methods like BOLFI require information about the gradient of logpdf. You can implement this as a classmethod `gradient_logpdf` with the same call signature as `logpdf`, and return type of np.array. If this is unimplemented, ELFI will diff --git a/elfi/graphical_model.py b/elfi/model/graphical_model.py similarity index 100% rename from elfi/graphical_model.py rename to elfi/model/graphical_model.py diff --git a/elfi/utils.py b/elfi/utils.py index aa7c2000..a7af450d 100644 --- a/elfi/utils.py +++ b/elfi/utils.py @@ -70,7 +70,7 @@ def get_sub_seed(random_state, sub_seed_index, high=2**31): seen = set() while n_unique != n_unique_required: n_draws = n_unique_required - n_unique - sub_seeds = random_state.randint(high, size=n_draws) + sub_seeds = random_state.randint(high, size=n_draws, dtype='uint32') seen.update(sub_seeds) n_unique = len(seen) diff --git a/requirements.txt b/requirements.txt index eb5de10a..10e03501 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,4 +5,3 @@ GPy>=1.0.9 networkX>=1.11 ipyparallel>=6 toolz>=0.8 -numdifftools>=0.9.20 diff --git a/tests/conftest.py b/tests/conftest.py index c9556c20..9d3a5751 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,7 @@ import logging import time import os +import sys import numpy as np import pytest @@ -22,6 +23,9 @@ def pytest_addoption(parser): help="skip slow tests") +"""Functional fixtures""" + + @pytest.fixture(scope="session", params=[native, eipp]) def client(request): @@ -61,7 +65,13 @@ def use_logging(): logging.getLogger('elfi.executor').setLevel(logging.WARNING) -# Model fixtures +@pytest.fixture() +def skip_travis(): + if "TRAVIS" in os.environ and os.environ['TRAVIS'] == "true": + pytest.skip("Skipping this test in Travis CI due to very slow run-time. Tested locally!") + + +"""Model fixtures""" @pytest.fixture() @@ -107,7 +117,75 @@ def sleep_model(request): m.observed['slept'] = ub_sec/2 return m + +"""Helper fixtures""" + + @pytest.fixture() -def skip_travis(): - if "TRAVIS" in os.environ and os.environ['TRAVIS'] == "true": - pytest.skip("Skipping this test in Travis CI due to very slow run-time. Tested locally!") +def distribution_test(): + + def test_non_rvs_attr(attr, distribution, rvs, *args, **kwargs): + # Run some tests that ensure outputs are coherent (similar style as with e.g. + # scipy distributions) + + rvs_none, rvs1, rvs2 = rvs + attr_fn = getattr(distribution, attr) + + # Test pdf + attr_none = attr_fn(rvs_none, *args, **kwargs) + attr1 = attr_fn(rvs1, *args, **kwargs) + attr2 = attr_fn(rvs2, *args, **kwargs) + + # With size=1 the length should be 1 + assert len(attr1) == 1 + assert len(attr2) == 2 + + assert attr1.shape[1:] == attr2.shape[1:] + + assert attr_none.shape == attr1.shape[1:] + + # With size=None we should get data that is not wrapped to any extra dim + return attr_none, attr1, attr2 + + def run(distribution, *args, rvs=None, **kwargs): + + if rvs is None: + # Run some tests that ensure outputs are similar to e.g. scipy distributions + rvs_none = distribution.rvs(*args, size=None, **kwargs) + rvs1 = distribution.rvs(*args, size=1, **kwargs) + rvs2 = distribution.rvs(*args, size=2, **kwargs) + else: + rvs_none, rvs1, rvs2 = rvs + + # Test that if rvs_none should be a scalar but is wrapped + assert rvs_none.squeeze().ndim == rvs_none.ndim + + # With size=1 the length should be 1 + assert len(rvs1) == 1 + assert len(rvs2) == 2 + + assert rvs1.shape[1:] == rvs2.shape[1:] + + # With size=None we should get data that is not wrapped to any extra dim + # (possibly a scalar) + assert rvs_none.shape == rvs1.shape[1:] + + rvs = (rvs_none, rvs1, rvs2) + + # Test pdf + pdf_none, pdf1, pdf2 = test_non_rvs_attr('pdf', distribution, rvs, *args, **kwargs) + # Should be a scalar + assert pdf_none.ndim == 0 + + if hasattr(distribution, 'logpdf'): + logpdf_none, logpdf1, logpdf2 = test_non_rvs_attr('logpdf', distribution, rvs, *args, **kwargs) + assert np.allclose(logpdf_none, np.log(pdf_none)) + assert np.allclose(logpdf1, np.log(pdf1)) + assert np.allclose(logpdf2, np.log(pdf2)) + + if hasattr(distribution, 'gradient_logpdf'): + glpdf_none, glpdf1, glpdf2 = test_non_rvs_attr('gradient_logpdf', distribution, rvs, *args, **kwargs) + + + return run + diff --git a/tests/functional/test_inference.py b/tests/functional/test_inference.py index 6b09cd1a..c3c8252e 100644 --- a/tests/functional/test_inference.py +++ b/tests/functional/test_inference.py @@ -6,7 +6,7 @@ import elfi from elfi.examples import ma2 from elfi.model.elfi_model import NodeReference - +from elfi.methods.bo.utils import stochastic_optimization, minimize slow = pytest.mark.skipif( pytest.config.getoption("--skipslow"), @@ -105,7 +105,7 @@ def test_BOLFI(): log_d = NodeReference(m['d'], state=dict(_operation=np.log), model=m, name='log_d') bolfi = elfi.BOLFI(log_d, initial_evidence=20, update_interval=10, batch_size=5, - bounds=[(-2,2), (-1, 1)]) + bounds=[(-2,2), (-1, 1)], acq_noise_cov=.1) n = 300 res = bolfi.infer(n) assert bolfi.target_model.n_evidence == n @@ -122,8 +122,15 @@ def test_BOLFI(): post = bolfi.infer_posterior() - post_ml = post.ML - post_map = post.MAP + # TODO: make cleaner. + post_ml = minimize(post._neg_unnormalized_loglikelihood, post._gradient_neg_unnormalized_loglikelihood, + post.model.bounds, post.prior, post.n_inits, post.max_opt_iters, + random_state=post.random_state)[0] + # TODO: Here we cannot use the minimize method due to sharp edges in the posterior. + # If a MAP method is implemented, one must be able to set the optimizer and + # provide its options. + post_map = stochastic_optimization(post._neg_unnormalized_logposterior, + post.model.bounds)[0] vals_ml = dict(t1=np.array([post_ml[0]]), t2=np.array([post_ml[1]])) check_inference_with_informative_data(vals_ml, 1, true_params, error_bound=.2) vals_map = dict(t1=np.array([post_map[0]]), t2=np.array([post_map[1]])) @@ -145,11 +152,11 @@ def test_BOLFI(): grad_mu, grad_var = bolfi.target_model._gp.predictive_gradients(x) grad_cached_mu, grad_cached_var = bolfi.target_model.predictive_gradients(x) - assert(np.allclose(grad_mu, grad_cached_mu)) + assert(np.allclose(grad_mu[:,:,0], grad_cached_mu)) assert(np.allclose(grad_var, grad_cached_var)) # test calculation of prior logpdfs true_logpdf_prior = ma2.CustomPrior1.logpdf(x[0, 0], 2) true_logpdf_prior += ma2.CustomPrior2.logpdf(x[0, 1], x[0, 0,], 1) - assert np.isclose(true_logpdf_prior, post._logprior_density(x[0, :])) + assert np.isclose(true_logpdf_prior, post.prior.logpdf(x[0, :])) diff --git a/tests/unit/test_methods.py b/tests/unit/test_methods.py index afbaab54..3a41fea8 100644 --- a/tests/unit/test_methods.py +++ b/tests/unit/test_methods.py @@ -1,5 +1,4 @@ import pytest -import logging import numpy as np @@ -21,14 +20,14 @@ def test_smc_prior_use(ma2): N = 1000 smc = elfi.SMC(ma2['d'], batch_size=20000) res = smc.sample(N, thresholds=thresholds) - dens = res.populations[0].outputs['_prior_pdf'] + dens = res.populations[0].outputs[smc.prior_pdf] # Test that the density is uniform assert np.allclose(dens, dens[0]) # very superficial test to compensate for test_inference.test_BOLFI not being run on Travis @pytest.mark.usefixtures('with_all_clients') -def test_BOLFI_short(ma2): +def test_BOLFI_short(ma2, distribution_test): # Log discrepancy tends to work better log_d = elfi.Operation(np.log, ma2['d']) @@ -47,8 +46,7 @@ def test_BOLFI_short(ma2): post = bolfi.infer_posterior() - post_ml = post.ML - post_map = post.MAP + distribution_test(post, rvs=(acq_x[0,:], acq_x[1:2,:], acq_x[2:4,:])) n_samples = 10 n_chains = 2 @@ -67,5 +65,5 @@ def test_BOLFI_short(ma2): grad_mu, grad_var = bolfi.target_model._gp.predictive_gradients(x) grad_cached_mu, grad_cached_var = bolfi.target_model.predictive_gradients(x) - assert(np.allclose(grad_mu, grad_cached_mu)) + assert(np.allclose(grad_mu[:,:,0], grad_cached_mu)) assert(np.allclose(grad_var, grad_cached_var)) diff --git a/tests/unit/test_results.py b/tests/unit/test_results.py index 86b8eac0..3dec7520 100644 --- a/tests/unit/test_results.py +++ b/tests/unit/test_results.py @@ -1,6 +1,7 @@ import pytest from elfi.methods.results import * +from elfi.methods.posteriors import BolfiPosterior def test_Result(): @@ -62,3 +63,7 @@ def test_ResultBOLFI(): assert hasattr(result, 'something') assert result.something_else == 'y' + + +def test_bolfi_posterior(ma2): + pass \ No newline at end of file diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py index e3c8e76e..be84ae0d 100644 --- a/tests/unit/test_utils.py +++ b/tests/unit/test_utils.py @@ -1,8 +1,10 @@ import numpy as np import scipy.stats as ss -from elfi.methods.utils import weighted_var, GMDistribution, normalize_weights -from elfi.methods.bo.utils import stochastic_optimization, minimize, numerical_gradient_logpdf +import elfi +from elfi.methods.utils import weighted_var, GMDistribution, normalize_weights, \ + ModelPrior, numgrad +from elfi.methods.bo.utils import stochastic_optimization, minimize def test_stochastic_optimization(): @@ -19,22 +21,11 @@ def test_minimize(): fun = lambda x : x[0]**2 + (x[1]-1)**4 grad = lambda x : np.array([2*x[0], 4*(x[1]-1)**3]) bounds = ((-2, 2), (-2, 3)) - priors = [None, None] - loc, val = minimize(fun, grad, bounds, priors) + loc, val = minimize(fun, grad, bounds) assert np.isclose(val, 0, atol=0.01) assert np.allclose(loc, np.array([0, 1]), atol=0.02) -def test_numerical_grad_logpdf(): - dist = ss.norm - loc = 2.2 - scale = 1.1 - x = np.random.rand() - grad_logpdf = -(x-loc)/scale**2 - num_grad = numerical_gradient_logpdf(x, loc, scale, distribution=dist) - assert np.isclose(grad_logpdf, num_grad, atol=0.01) - - def test_weighted_var(): # 1d case std = .3 @@ -51,7 +42,7 @@ def test_weighted_var(): class TestGMDistribution: - def test_pdf(self): + def test_pdf(self, distribution_test): # 1d case x = [1, 2, -1] means = [0, 2] @@ -60,6 +51,12 @@ def test_pdf(self): d_true = weights[0]*ss.norm.pdf(x, loc=means[0]) + weights[1]*ss.norm.pdf(x, loc=means[1]) assert np.allclose(d, d_true) + # Test with a single observation + # assert GMDistribution.pdf(x[0], means, weights=weights).ndim == 0 + + # Distribution_test with 1d means + distribution_test(GMDistribution, means, weights=weights) + # 2d case x = [[1, 2, -1], [0,0,2]] means = [[0,0,0], [-1,-.2, .1]] @@ -68,6 +65,12 @@ def test_pdf(self): weights[1]*ss.multivariate_normal.pdf(x, mean=means[1]) assert np.allclose(d, d_true) + # Test with a single observation + assert GMDistribution.pdf(x[0], means, weights=weights).ndim == 0 + + # Distribution_test with 3d means + distribution_test(GMDistribution, means, weights=weights) + def test_rvs(self): means = [[1000, 3], [-1000, -3]] weights = [.3, .7] @@ -81,3 +84,45 @@ def test_rvs(self): # Test that the mean of the second mode is correct assert np.abs(np.mean(rvs[:,1]) + 3) < .1 + + +def test_numgrad(): + assert np.allclose(numgrad(lambda x: np.log(x), 3), [1/3]) + assert np.allclose(numgrad(lambda x: np.prod(x, axis=1), [1, 3, 5]), [15, 5, 3]) + assert np.allclose(numgrad(lambda x: np.sum(x, axis=1), [1, 3, 5]), [1, 1, 1]) + + +class TestModelPrior: + + def test_basics(self, ma2, distribution_test): + # A 1D case + normal = elfi.Prior('normal', 5, model=elfi.ElfiModel()) + normal_prior = ModelPrior(normal.model) + distribution_test(normal_prior) + + # A 2D case + prior = ModelPrior(ma2) + distribution_test(prior) + + def test_pdf(self, ma2): + prior = ModelPrior(ma2) + rv = prior.rvs(size=10) + assert np.allclose(prior.pdf(rv), np.exp(prior.logpdf(rv))) + + def test_gradient_logpdf(self, ma2): + prior = ModelPrior(ma2) + rv = prior.rvs(size=10) + grads = prior.gradient_logpdf(rv) + assert grads.shape == rv.shape + assert np.allclose(grads, 0) + + def test_numerical_grad_logpdf(self): + # Test gradient with a normal distribution + loc = 2.2 + scale = 1.1 + x = np.random.rand() + analytical_grad_logpdf = -(x - loc) / scale ** 2 + prior_node = elfi.Prior('normal', loc, scale, model=elfi.ElfiModel()) + num_grad = ModelPrior(prior_node.model).gradient_logpdf(x) + assert np.isclose(num_grad, analytical_grad_logpdf, atol=0.01) + From aa5380456cdedee05e04de515b57066fd44f1e99 Mon Sep 17 00:00:00 2001 From: Jarno Lintusaari Date: Mon, 19 Jun 2017 16:35:28 +0300 Subject: [PATCH 12/38] Docs: implementing a new method + related refactorings (#174) * Renamed InferenceMethod to ParameterInference * Added a custom method documentation * Review changes * Numpy 1.13 fix --- CHANGELOG.rst | 14 +- docs/api.rst | 17 +- docs/index.rst | 1 + docs/usage/implementing-methods.rst | 292 ++++++++++- elfi/__init__.py | 2 +- elfi/client.py | 4 +- .../{methods.py => parameter_inference.py} | 462 +++++++++--------- elfi/methods/posteriors.py | 2 +- elfi/methods/results.py | 98 ++-- elfi/methods/utils.py | 12 +- elfi/model/augmenter.py | 4 +- elfi/model/elfi_model.py | 35 +- tests/functional/test_inference.py | 10 +- tests/functional/test_simulation_reuse.py | 2 +- tests/unit/test_bo.py | 2 +- tests/unit/test_document_examples.py | 78 +++ tests/unit/test_elfi_model.py | 4 +- tests/unit/test_examples.py | 4 + tests/unit/test_methods.py | 8 +- tests/unit/test_results.py | 10 +- 20 files changed, 731 insertions(+), 330 deletions(-) rename elfi/methods/{methods.py => parameter_inference.py} (72%) create mode 100644 tests/unit/test_document_examples.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 70fb1906..a51cec32 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,13 +1,23 @@ Changelog ========== -0.5.x ------ +dev branch (upcoming version) +----------------------------- + +- Changed some of the internal variable names in methods.py. Most notable outputs is now + output_names. +- methods.py renamed to parameter_inference.py +- Changes in elfi.methods.results module class names: + - OptimizationResult (a new result type) + - Result -> Sample + - ResultSMC -> SmcSample + - ResultBOLFI -> BolfiSample - BO/BOLFI: take advantage of priors - BO/BOLFI: take advantage of seed - BO/BOLFI: improved optimization scheme + 0.5 (2017-05-19) ---------------- diff --git a/docs/api.rst b/docs/api.rst index 62d5f9b0..39306e1e 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -54,9 +54,10 @@ Below is a list of inference methods included in ELFI. .. currentmodule:: elfi.methods.results .. autosummary:: - Result - ResultSMC - ResultBOLFI + OptimizationResult + Sample + SmcSample + BolfiSample Other ----- @@ -165,15 +166,19 @@ Inference API classes .. currentmodule:: elfi.methods.results -.. autoclass:: Result +.. autoclass:: OptimizationResult :members: :inherited-members: -.. autoclass:: ResultSMC +.. autoclass:: Sample :members: :inherited-members: -.. autoclass:: ResultBOLFI +.. autoclass:: SmcSample + :members: + :inherited-members: + +.. autoclass:: BolfiSample :members: :inherited-members: diff --git a/docs/index.rst b/docs/index.rst index a18baaf7..ea23c074 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -50,6 +50,7 @@ ELFI also has the following non LFI methods: usage/tutorial usage/parallelization usage/external + usage/implementing-methods .. toctree:: :maxdepth: 1 diff --git a/docs/usage/implementing-methods.rst b/docs/usage/implementing-methods.rst index 142470c6..6747589e 100644 --- a/docs/usage/implementing-methods.rst +++ b/docs/usage/implementing-methods.rst @@ -1,2 +1,290 @@ -Implementing new methods -======================== \ No newline at end of file +Implementing a new inference method +=================================== + +This tutorial provides the fundamentals for implementing custom parameter inference +methods using ELFI. ELFI provides many features out of the box, such as parallelization or +random state handling. In a typical case these happen "automatically" behind the scenes +when the algorithms are built on top of the provided interface classes. + +The base class for parameter inference classes is the `ParameterInference`_ interface +which is found from the ``elfi.methods.parameter_inference`` module. Among the methods in +the interface, those that must be implemented raise a ``NotImplementedError``. In +addition, you probably also want to override at least the ``update`` and ``__init__`` +methods. + +Let's create an empty skeleton for a custom method that includes just the minimal set of +methods to create a working algorithm in ELFI:: + + from elfi.methods.parameter_inference import ParameterInference + + class CustomMethod(ParameterInference): + + def __init__(self, model, output_names, **kwargs): + super(CustomMethod, self).__init__(model, output_names, **kwargs) + + def set_objective(self): + # Request 3 batches to be generated + self.objective['n_batches'] = 3 + + def extract_result(self): + return self.state + +The method ``extract_result`` is called by ELFI in the end of inference and should return +a ``ParameterInferenceResult`` object (``elfi.methods.result`` module). For illustration +we will however begin by returning the member ``state`` dictionary. It stores all the +current state information of the inference. Let's make an instance of our method and run +it:: + + import elfi.examples.ma2 as ma2 + + # Get a ready made MA2 model to test our inference method with + m = ma2.get_model() + + # We want the outputs from node 'd' of the model `m` to be available + custom_method = CustomMethod(m, ['d']) + + # Run the inference + custom_method.infer() # {'n_batches': 3, 'n_sim': 3000} + +Running the above returns the state dictionary. We will find a few keys in it that track +some basic properties of the state, such as the ``n_batches`` telling how many batches has +been generated and ``n_sim`` that tells the number of total simulations contained in those +batches. It should be ``n_batches`` times the current batch size +(``custom_method.batch_size`` which was 1000 here by default). + +You will find that the ``n_batches`` in the state dictionary had a value 3. This is +because in our ``CustomMethod.set_objective`` method, we set the ``n_batches`` key of the +objective dictionary to that value. Every `ParameterInference`_ instance has a Python +dictionary called ``objective`` that is a counterpart to the ``state`` dictionary. The +objective defines the conditions when the inference is finished. The default controlling +key in that dictionary is the string ``n_batches`` whose value tells ELFI how many batches +we need to generate in total from the provided generative ``ElfiModel`` model. Inference +is considered finished when the ``n_batches`` in the ``state`` matches or exceeds that in +the ``objective``. The generation of batches is automatically parallelized in the +background, so we don't have to worry about it. + +.. note:: A batch in ELFI is a dictionary that maps names of nodes of the generative model + to their outputs. An output in the batch consists of one or more runs of it's operation + stored to a numpy array. Each batch has an index, and the outputs in the same batch are + guaranteed to be the same if you recompute the batch. + +The algorithm, however, does nothing else at this point besides generating the 3 batches. +To actually do something with the batches, we can add the ``update`` method that allows us +to update the state dictionary of the inference with any custom values. It takes in the +generated ``batch`` dictionary and it's index and is called by ELFI every time a new batch +is received. Let's say we wish to filter parameters by a threshold (as in ABC Rejection +sampling) from the total number of simulations:: + + class CustomMethod(ParameterInference): + def __init__(self, model, output_names, **kwargs): + super(CustomMethod, self).__init__(model, output_names, **kwargs) + + # Hard code a threshold and discrepancy node name for now + self.threshold = .1 + self.discrepancy_name = output_names[0] + + # Prepare lists to push the filtered outputs into + self.state['filtered_outputs'] = {name: [] for name in output_names} + + def update(self, batch, batch_index): + super(CustomMethod, self).update(batch, batch_index) + + # Make a filter mask (logical numpy array) from the distance array + filter_mask = batch[self.discrepancy_name] <= self.threshold + + # Append the filtered parameters to their lists + for name in self.output_names: + values = batch[name] + self.state['filtered_outputs'][name].append(values[filter_mask]) + + ... # other methods as before + + m = ma2.get_model() + custom_method = CustomMethod(m, ['d']) + custom_method.infer() # {'n_batches': 3, 'n_sim': 3000, 'filtered_outputs': ...} + +After running this you should have in the returned state dictionary the +``filtered_outputs`` key containing filtered distances for node ``d`` from the 3 batches. + +.. note:: The reason for the imposed structure in ``ParameterInference`` is to encourage a + design where one can advance the inference iteratively using the ``iterate`` method. + This makes it possible to stop at any point, check the current state and to be able to + continue. This is important as there are usually many moving parts, such as summary + statistic choices or deciding a good discrepancy function. + +Now to be useful, we should allow the user to set the different options - the 3 batches is +not going to take her very far. The user also probably thinks in terms of simulations +rather than batches. ELFI allows you to replace the ``n_batches`` with ``n_sim`` key +in the objective to spare you from turning ``n_sim`` to ``n_batches`` in the code. Just +note that the ``n_sim`` in the state will always be in multiples of the ``batch_size``. + +Let's modify the algorithm so, that the user can pass the threshold, the name of the +discrepancy node and the number of simulations. And let's also add the parameters to the +outputs:: + + class CustomMethod(ParameterInference): + def __init__(self, model, discrepancy_name, threshold, **kwargs): + # Create a name list of nodes whose outputs we wish to receive + output_names = [discrepancy_name] + model.parameter_names + super(CustomMethod, self).__init__(model, output_names, **kwargs) + + self.threshold = threshold + self.discrepancy_name = discrepancy_name + + # Prepare lists to push the filtered outputs into + self.state['filtered_outputs'] = {name: [] for name in output_names} + + def set_objective(self, n_sim): + self.objective['n_sim'] = n_sim + + ... # other methods as before + + # Run it + custom_method = CustomMethod(m, 'd', threshold=.1, batch_size=1000) + custom_method.infer(n_sim=2000) # {'n_batches': 2, 'n_sim': 2000, 'filtered_outputs': ...} + +Calling the inference method now returns the state dictionary that has also the filtered +parameters in it from each of the batches. Note that any arguments given to the ``infer`` +method are passed to the ``set_objective`` method. + +Now due to the structure of the algorithm the user can immediately continue from this +state:: + + # Continue inference from the previous state (with n_sim=2000) + custom_method.infer(n_sim=4000) # {'n_batches': 4, 'n_sim': 4000, 'filtered_outputs': ...} + + # Or use it iteratively + custom_method.set_objective(n_sim=6000) + + custom_method.iterate() + assert custom_method.finished == False + # Investigate the current state + custom_method.extract_result() # {'n_batches': 5, 'n_sim': 5000, 'filtered_outputs': ...} + + self.iterate() + assert custom_method.finished + custom_method.extract_result() # {'n_batches': 6, 'n_sim': 6000, 'filtered_outputs': ...} + +This works, because the state is stored into the ``custom_method`` instance, and we only +change the objective. Also ELFI calls ``iterate`` internally in the ``infer`` method. + +The last finishing touch to our algorithm is to convert the ``state`` dict to a more user +friendly format in the ``extract_result`` method. First we want to convert the list of +filtered arrays from the batches to a numpy array. We will then wrap the result to a +``elfi.methods.results.Sample`` object and return it instead of the ``state`` dict. Below +is the final complete implementation of our inference method class:: + + import numpy as np + + from elfi.methods.parameter_inference import ParameterInference + from elfi.methods.results import Sample + + + class CustomMethod(ParameterInference): + def __init__(self, model, discrepancy_name, threshold, **kwargs): + # Create a name list of nodes whose outputs we wish to receive + output_names = [discrepancy_name] + model.parameter_names + super(CustomMethod, self).__init__(model, output_names, **kwargs) + + self.threshold = threshold + self.discrepancy_name = discrepancy_name + + # Prepare lists to push the filtered outputs into + self.state['filtered_outputs'] = {name: [] for name in output_names} + + def set_objective(self, n_sim): + self.objective['n_sim'] = n_sim + + def update(self, batch, batch_index): + super(CustomMethod, self).update(batch, batch_index) + + # Make a filter mask (logical numpy array) from the distance array + filter_mask = batch[self.discrepancy_name] <= self.threshold + + # Append the filtered parameters to their lists + for name in self.output_names: + values = batch[name] + self.state['filtered_outputs'][name].append(values[filter_mask]) + + def extract_result(self): + filtered_outputs = self.state['filtered_outputs'] + outputs = {name: np.concatenate(filtered_outputs[name]) for name in self.output_names} + + return Sample( + method_name='CustomMethod', + outputs=outputs, + parameter_names=self.parameter_names, + discrepancy_name=self.discrepancy_name, + n_sim=self.state['n_sim'], + threshold=self.threshold + ) + +Running the inference with the above implementation should now produce an user friendly +output:: + + Method: CustomMethod + Number of posterior samples: 82 + Number of simulations: 10000 + Threshold: 0.1 + Posterior means: t1: 0.687, t2: 0.152 + + +Where to go from here +..................... + +When implementing your own method it is advisable to read the documentation of the +`ParameterInference`_ class. In addition we recommend reading the ``Rejection``, ``SMC`` +and/or ``BayesianOptimization`` class implementations from the source for some more +advanced techniques. These methods feature e.g. how to inject values from outside into the +ELFI model (acquisition functions in BayesianOptimization), how to modify the user +provided model to get e.g. the pdf:s of the parameters (SMC) and so forth. + +Good to know +------------ + +ELFI guarantees that computing a batch with the same index will always produce the same +output given the same model and ``ComputationContext`` object. The ``ComputationContext`` +object holds the batch size, seed for the PRNG, the pool object of precomputed batches +of nodes. If your method uses random quantities in the algorithm, please make sure +to use the seed attribute of ``ParameterInference`` so that your results will be +consistent. + +If you want to provide values for outputs of certain nodes from outside the generative +model, you can return them from ``prepare_new_batch`` method. They will replace any +default value or operation in that node. This is used e.g. in ``BOLFI`` where values from +the acquisition function replace values coming from the prior in the Bayesian optimization +phase. + +The `ParameterInference`_ instance has also the following helper classes: + +``BatchHandler`` +................ + +`ParameterInference`_ class instantiates a ``elfi.client.BatchHandler`` helper class that +is set as the ``self.batches`` member variable. This object is in essence a wrapper to the +``Client`` interface making it easier to work with batches that are in computation. Some +of the duties of ``BatchHandler`` is to keep track of the current batch_index and of the +status of the batches that have been submitted. You often don't need to interact with it +directly. + +``OutputPool`` +.............. + +``elfi.store.OutputPool`` serves a dual purpose: +1. It stores all the computed outputs of selected nodes +2. It provides those outputs when a batch is recomputed saving the need to recompute them. + +Note however that reusing the values is not always possible. In sequential algorithms that +decide their next parameter values based on earlier results, modifications to the ELFI +model will invalidate the earlier data. On the other hand, Rejection sampling for instance +allows changing any of the summaries or distances and still reuse e.g. the simulations. +This is because all the parameter values will still come from the same priors. + +.. _`ParameterInference`: `Parameter inference base class`_ + +Parameter inference base class +------------------------------ + +.. autoclass:: elfi.methods.parameter_inference.ParameterInference + :members: + :inherited-members: \ No newline at end of file diff --git a/elfi/__init__.py b/elfi/__init__.py index addd5b6a..d5f72dcf 100644 --- a/elfi/__init__.py +++ b/elfi/__init__.py @@ -5,7 +5,7 @@ import elfi.methods.mcmc import elfi.model.tools as tools from elfi.client import get_client, set_client -from elfi.methods.methods import * +from elfi.methods.parameter_inference import * from elfi.model.elfi_model import * from elfi.model.extensions import ScipyLikeDistribution as Distribution from elfi.store import OutputPool, ArrayPool diff --git a/elfi/client.py b/elfi/client.py index 30f858af..3df15c77 100644 --- a/elfi/client.py +++ b/elfi/client.py @@ -45,9 +45,9 @@ class BatchHandler: Responsible for sending computational graphs to be executed in an Executor """ - def __init__(self, model, outputs=None, client=None): + def __init__(self, model, output_names=None, client=None): self.client = client or get_client() - self.compiled_net = self.client.compile(model.source_net, outputs) + self.compiled_net = self.client.compile(model.source_net, output_names) self.context = model.computation_context self._next_batch_index = 0 diff --git a/elfi/methods/methods.py b/elfi/methods/parameter_inference.py similarity index 72% rename from elfi/methods/methods.py rename to elfi/methods/parameter_inference.py index 51d6ff04..671aa197 100644 --- a/elfi/methods/methods.py +++ b/elfi/methods/parameter_inference.py @@ -15,8 +15,8 @@ from elfi.methods.bo.acquisition import LCBSC from elfi.methods.bo.gpy_regression import GPyRegression from elfi.methods.bo.utils import stochastic_optimization -from elfi.methods.results import Result, ResultSMC, ResultBOLFI from elfi.methods.posteriors import BolfiPosterior +from elfi.methods.results import Sample, SmcSample, BolfiSample, OptimizationResult from elfi.methods.utils import GMDistribution, weighted_var, ModelPrior from elfi.model.elfi_model import ComputationContext, NodeReference, ElfiModel @@ -25,103 +25,39 @@ __all__ = ['Rejection', 'SMC', 'BayesianOptimization', 'BOLFI'] -""" +# TODO: refactor the plotting functions -Implementing a new inference method ------------------------------------ -You can implement your own algorithm by subclassing the `InferenceMethod` class. The -methods that must be implemented raise `NotImplementedError`. In addition, you will -probably also want to override the `__init__` method. It can be useful to read through -`Rejection`, `SMC` and/or `BayesianOptimization` class implementations below to get you -going. The reason for the imposed structure in `InferenceMethod` is to encourage a design -where one can advance the inference iteratively, that is, to stop at any point, check the -current state and to be able to continue. This makes it possible to effectively tune the -inference as there are usually many moving parts, such as summary statistic choices or -deciding the best discrepancy function. +class ParameterInference: + """A base class for parameter inference methods. -ELFI operates through batches. A batch is an indexed collection of one or more successive -outputs from the generative model (`ElfiModel`). The rule of thumb is that it should take -a significant amount of time to compute a batch. This ensures that it is worthwhile to -send a batch over the network to a remote worker to be computed. A batch also needs to fit -into memory. - -ELFI guarantees that computing a batch with the same index will always produce the same -output given the same model and `ComputationContext` object. The `ComputationContext` -object holds the batch size, seed for the PRNG, and a pool of precomputed batches of nodes -and the observed values of the nodes. - -When a new `InferenceMethod` is constructed, it will make a copy of the user provided -`ElfiModel` and make a new `ComputationContext` object for it. The user's model will stay -intact and the algorithm is free to modify it's copy as it needs to. - - -### Implementing the `__init__` method - -You will need to call the `InferenceMethod.__init__` with a list of outputs, e.g. names of -nodes that you need the data for in each batch. For example, the rejection algorithm needs -the parameters and the discrepancy node output. - -The first parameter to your `__init__` can be either the ElfiModel object or directly a -"target" node, e.g. discrepancy in rejection sampling. Assuming your `__init__` takes an -optional discrepancy parameter, you can detect which one was passed by using -`_resolve_model` method: - -``` -def __init__(model, discrepancy, ...): - model, discrepancy = self._resolve_model(model, discrepancy) -``` - -In case you need multiple target nodes, you will need to write your own resolver. - - -### Explanations for some members of `InferenceMethod` - -- objective : dict - Holds the data for the algorithm to internally determine how many batches are still - needed. You must have a key `n_batches` here. This information is used to determine - when the algorithm is finished. - -- state : dict - Stores any temporal data related to achieving the objective. Must include a key - `n_batches` for determining when the inference is finished. - - -### Good to know - -#### `BatchHandler` - -`InferenceMethod` class instantiates a `elfi.client.BatchHandler` helper class for you and -assigns it to `self.batches`. This object is in essence a wrapper to the `Client` -interface making it easier to work with batches that are in computation. Some of the -duties of `BatchHandler` is to keep track of the current batch_index and of the status of -the batches that have been submitted. You may however may not need to interact with it -directly. - -#### `OutputPool` - -`elfi.store.OutputPool` serves a dual purpose: -1. It stores the computed outputs of selected nodes -2. It provides those outputs when a batch is recomputed saving the need to recompute them. - -If you want to provide values for outputs of certain nodes from outside the generative -model, you can return then in `prepare_new_batch` method. They will be inserted into to -the `OutputPool` and will replace any value that would have otherwise been generated from -the node. This is used e.g. in `BOLFI` where values from the acquisition function replace -values coming from the prior in the Bayesian optimization phase. - -""" - -# TODO: prevent loading from OutputPool in SMC and BO -# TODO: use only either n_batches or n_sim in state dict -# TODO: plan how continuing the inference is standardized + Attributes + ---------- + model : elfi.ElfiModel + The generative model used by the algorithm + output_names : list + Names of the nodes whose outputs are included in the batches + client : elfi.client.ClientBase + The batches are computed in the client + max_parallel_batches : int + state : dict + Stores any changing data related to achieving the objective. Must include a key + ``n_batches`` for determining when the inference is finished. + objective : dict + Holds the data for the algorithm to internally determine how many batches are still + needed. You must have a key ``n_batches`` here. By default the algorithm finished when + the ``n_batches`` in the state dictionary is equal or greater to the corresponding + objective value. + batches : elfi.client.BatchHandler + Helper class for submitting batches to the client and keeping track of their + indexes. + pool : elfi.store.OutputPool + Pool object for storing and reusing node outputs. -class InferenceMethod(object): - """ """ - def __init__(self, model, outputs, batch_size=1000, seed=None, pool=None, + def __init__(self, model, output_names, batch_size=1000, seed=None, pool=None, max_parallel_batches=None): """Construct the inference algorithm object. @@ -131,9 +67,8 @@ def __init__(self, model, outputs, batch_size=1000, seed=None, pool=None, ---------- model : ElfiModel Model to perform the inference with. - outputs : list - Contains the node names for which the algorithm needs to receive the outputs - in every batch. + output_names : list + Names of the nodes whose outputs will be requested from the generative model. batch_size : int seed : int, optional Seed for the data generation from the ElfiModel @@ -145,12 +80,12 @@ def __init__(self, model, outputs, batch_size=1000, seed=None, pool=None, """ - - if not model.parameters: + model = model.model if isinstance(model, NodeReference) else model + if not model.parameter_names: raise ValueError('Model {} defines no parameters'.format(model)) self.model = model.copy() - self.outputs = outputs + self.output_names = self._check_outputs(output_names) # Prepare the computation_context context = ComputationContext( @@ -161,7 +96,7 @@ def __init__(self, model, outputs, batch_size=1000, seed=None, pool=None, ) self.model.computation_context = context self.client = elfi.client.get_client() - self.batches = elfi.client.BatchHandler(self.model, outputs=outputs, client=self.client) + self.batches = elfi.client.BatchHandler(self.model, output_names=output_names, client=self.client) self.max_parallel_batches = max_parallel_batches or self.client.num_cores if self.max_parallel_batches <= 0: @@ -176,27 +111,33 @@ def __init__(self, model, outputs, batch_size=1000, seed=None, pool=None, # State and objective should contain all information needed to continue the # inference after an iteration. self.state = dict(n_sim=0, n_batches=0) - self.objective = dict(n_batches=0) + self.objective = dict() @property def pool(self): + """Return the output pool of the inference.""" return self.model.computation_context.pool @property def seed(self): + """Return the seed of the inference.""" return self.model.computation_context.seed @property - def parameters(self): - return self.model.parameters + def parameter_names(self): + """Return the parameters to be inferred.""" + return self.model.parameter_names @property def batch_size(self): + """Return the current batch_size.""" return self.model.computation_context.batch_size def set_objective(self, *args, **kwargs): - """This method is called when one wants to begin the inference. Set `self.state` - and `self.objective` here for the inference. + """Set the objective of the inference. + + This method sets the objective of the inference (values typically stored in the + `self.objective` dict). Returns ------- @@ -205,18 +146,22 @@ def set_objective(self, *args, **kwargs): raise NotImplementedError def extract_result(self): - """This method is called when one wants to receive the result from the inference. - You should prepare the output here and return it. + """Prepare the result from the current state of the inference. + + ELFI calls this method in the end of the inference to return the result. Returns ------- - result : dict + result : elfi.methods.result.Result """ raise NotImplementedError def update(self, batch, batch_index): - """ELFI calls this method when a new batch has been computed and the state of - the inference should be updated with it. + """Update the inference state with a new batch. + + ELFI calls this method when a new batch has been computed and the state of + the inference should be updated with it. It is also possible to bypass ELFI and + call this directly to update the inference. Parameters ---------- @@ -229,10 +174,14 @@ def update(self, batch, batch_index): ------- None """ - raise NotImplementedError + logger.info('Received batch %d' % batch_index) + self.state['n_batches'] += 1 + self.state['n_sim'] += self.batch_size def prepare_new_batch(self, batch_index): - """ELFI calls this method before submitting a new batch with an increasing index + """Prepare values for a new batch + + ELFI calls this method before submitting a new batch with an increasing index `batch_index`. This is an optional method to override. Use this if you have a need do do preparations, e.g. in Bayesian optimization algorithm, the next acquisition points would be acquired here. @@ -248,6 +197,8 @@ def prepare_new_batch(self, batch_index): Returns ------- batch : dict or None + Keys should match to node names in the model. These values will override any + default values or operations in those nodes. """ pass @@ -267,7 +218,7 @@ def _init_model(self, model): return model def plot_state(self, **kwargs): - """ + """Plot the current state of the algorithm. Parameters ---------- @@ -290,11 +241,13 @@ def plot_state(self, **kwargs): raise NotImplementedError def infer(self, *args, vis=None, **kwargs): - """Init the inference and start the iterate loop until the inference is finished. + """Set the objective and start the iterate loop until the inference is finished. + + See the other arguments from the `set_objective` method. Returns ------- - result : Result + result : Sample """ vis_opt = vis if isinstance(vis, dict) else {} @@ -308,6 +261,7 @@ def infer(self, *args, vis=None, **kwargs): self.batches.cancel_pending() if vis: self.plot_state(close=True, **vis_opt) + return self.extract_result() def iterate(self): @@ -344,7 +298,7 @@ def iterate(self): @property def finished(self): - return self.objective['n_batches'] <= self.state['n_batches'] + return self._objective_n_batches <= self.state['n_batches'] @property def _allow_submit(self): @@ -354,7 +308,19 @@ def _allow_submit(self): @property def _has_batches_to_submit(self): - return self.objective['n_batches'] > self.state['n_batches'] + self.batches.num_pending + return self._objective_n_batches > \ + self.state['n_batches'] + self.batches.num_pending + + @property + def _objective_n_batches(self): + """Checks that n_batches can be computed from the objective""" + if 'n_batches' in self.objective: + n_batches = self.objective['n_batches'] + elif 'n_sim' in self.objective: + n_batches = ceil(self.objective['n_sim']/self.batch_size) + else: + raise ValueError('Objective must define either `n_batches` or `n_sim`.') + return n_batches def _to_array(self, batches, outputs=None): """Helper method to turn batches into numpy array @@ -377,7 +343,7 @@ def _to_array(self, batches, outputs=None): return [] if not isinstance(batches, list): batches = [batches] - outputs = outputs or self.outputs + outputs = outputs or self.output_names rows = [] for batch_ in batches: @@ -385,10 +351,19 @@ def _to_array(self, batches, outputs=None): return np.vstack(rows) + def _extract_result_kwargs(self): + """Extract common arguments for the ParameterInferenceResult object from the + inference instance. + """ + return { + 'method_name': self.__class__.__name__, + 'parameter_names': self.parameter_names, + 'seed': self.seed, + 'n_sim': self.state['n_sim'], + } + @staticmethod def _resolve_model(model, target, default_reference_class=NodeReference): - # TODO: extract the default_reference_class from the model? - if isinstance(model, ElfiModel) and target is None: raise NotImplementedError("Please specify the target node of the inference method") @@ -404,40 +379,58 @@ def _resolve_model(model, target, default_reference_class=NodeReference): return model, target.name - @staticmethod - def _compose_outputs(*output_args): - outputs = [] - for arg in output_args: - if arg is None: + def _check_outputs(self, output_names): + """Filters out duplicates, checks that corresponding nodes exist and preserves + the order.""" + output_names = output_names or [] + checked_names = [] + seen = set() + for name in output_names: + if isinstance(name, NodeReference): + name = name.name + + if name in seen: continue + elif not isinstance(name, str): + raise ValueError('All output names must be strings, object {} was given'.format(name)) + elif not self.model.has_node(name): + raise ValueError('Node {} output was requested, but it is not in the model.') - if isinstance(arg, str): - arg = [arg] - elif not isinstance(arg, (list, tuple)): - raise ValueError('Unknown output argument type: {}'.format(arg)) + seen.add(name) + checked_names.append(name) - for output in arg: - if output not in outputs: - outputs.append(output) + return checked_names - return outputs - -class Sampler(InferenceMethod): +class Sampler(ParameterInference): def sample(self, n_samples, *args, **kwargs): - """ + """Sample from the approximate posterior + + See the other arguments from the `set_objective` method. + Parameters ---------- n_samples : int Number of samples to generate from the (approximate) posterior + *args + **kwargs Returns ------- - result : Result + result : Sample """ return self.infer(n_samples, *args, **kwargs) + def _extract_result_kwargs(self): + kwargs = super(Sampler, self)._extract_result_kwargs() + for state_key in ['threshold', 'accept_rate']: + if state_key in self.state: + kwargs[state_key] = self.state[state_key] + if hasattr(self, 'discrepancy_name'): + kwargs['discrepancy_name'] = self.discrepancy_name + return kwargs + class Rejection(Sampler): """Parallel ABC rejection sampler. @@ -452,21 +445,26 @@ class Rejection(Sampler): http://dx.doi.org/10.1093/sysbio/syw077. """ - def __init__(self, model, discrepancy=None, outputs=None, **kwargs): + def __init__(self, model, discrepancy_name=None, output_names=None, **kwargs): """ Parameters ---------- model : ElfiModel or NodeReference - discrepancy : str or NodeReference + discrepancy_name : str, NodeReference, optional Only needed if model is an ElfiModel + output_names : list + Additional outputs from the model to be included in the inference result, e.g. + corresponding summaries to the acquired samples kwargs: See InferenceMethod """ - model, self.discrepancy = self._resolve_model(model, discrepancy) - outputs = self._compose_outputs(outputs, model.parameters, self.discrepancy) - super(Rejection, self).__init__(model, outputs, **kwargs) + model, discrepancy_name = self._resolve_model(model, discrepancy_name) + output_names = [discrepancy_name] + model.parameter_names + (output_names or []) + super(Rejection, self).__init__(model, output_names, **kwargs) + + self.discrepancy_name = discrepancy_name def set_objective(self, n_samples, threshold=None, quantile=None, n_sim=None): """ @@ -520,43 +518,31 @@ def extract_result(self): Returns ------- - result : Result + result : Sample """ if self.state['samples'] is None: raise ValueError('Nothing to extract') # Take out the correct number of samples - n_samples = self.objective['n_samples'] outputs = dict() for k, v in self.state['samples'].items(): - outputs[k] = v[:n_samples] + outputs[k] = v[:self.objective['n_samples']] - result = Result(method_name=self.__class__.__name__, - outputs=outputs, - parameter_names=self.parameters, - discrepancy_name=self.discrepancy, - threshold=self.state['threshold'], - n_sim=self.state['n_sim'], - accept_rate=self.state['accept_rate'], - seed=self.seed - ) - - return result + return Sample(outputs=outputs, **self._extract_result_kwargs()) def _init_samples_lazy(self, batch): - # Initialize the outputs dict based on the received batch + """Initialize the outputs dict based on the received batch""" samples = {} - for node in self.outputs: + e_len = "Node {} output length was {}. It should be equal to the batch size {}." + e_nolen = "Node {} output has no length. It should be equal to the batch size {}." + + for node in self.output_names: # Check the requested outputs try: if len(batch[node]) != self.batch_size: - raise ValueError("Node {} output length was {}. It should be equal " - "to the batch size {}.".format(node, - len(batch[node]), - self.batch_size)) + raise ValueError(e_len.format(node, len(batch[node]), self.batch_size)) except TypeError: - raise ValueError("Node {} output has no length. It should be equal to " - "the batch size {}.".format(node, self.batch_size)) + raise ValueError(e_nolen.format(node, self.batch_size)) except KeyError: raise KeyError("Did not receive outputs for node {}".format(node)) @@ -568,17 +554,14 @@ def _init_samples_lazy(self, batch): self.state['samples'] = samples def _merge_batch(self, batch): - # TODO: add index vector so that you can recover the original order, also useful - # for async - + # TODO: add index vector so that you can recover the original order samples = self.state['samples'] - # Put the acquired samples to the end for node, v in samples.items(): v[self.objective['n_samples']:] = batch[node] # Sort the smallest to the beginning - sort_mask = np.argsort(samples[self.discrepancy], axis=0).ravel() + sort_mask = np.argsort(samples[self.discrepancy_name], axis=0).ravel() for k, v in samples.items(): v[:] = v[sort_mask] @@ -589,7 +572,7 @@ def _update_state_meta(self): s = self.state s['n_batches'] += 1 s['n_sim'] += self.batch_size - s['threshold'] = s['samples'][self.discrepancy][o['n_samples'] - 1].item() + s['threshold'] = s['samples'][self.discrepancy_name][o['n_samples'] - 1].item() s['accept_rate'] = min(1, o['n_samples']/s['n_sim']) def _update_objective(self): @@ -600,7 +583,7 @@ def _update_objective(self): t, n_samples = [self.objective.get(k) for k in ('threshold', 'n_samples')] # noinspection PyTypeChecker - n_acceptable = np.sum(s['samples'][self.discrepancy] <= t) if s['samples'] else 0 + n_acceptable = np.sum(s['samples'][self.discrepancy_name] <= t) if s['samples'] else 0 if n_acceptable == 0: return accept_rate_t = n_acceptable / s['n_sim'] @@ -618,24 +601,26 @@ def plot_state(self, **options): displays.append(display.HTML( 'Threshold: {}'.format(self.state['threshold']))) - visin.plot_sample(self.state['samples'], nodes=self.parameters, - n=self.objective['n_samples'], displays=displays, **options) + visin.plot_sample(self.state['samples'], nodes=self.parameter_names, + n=self.objective['n_samples'], displays=displays, **options) class SMC(Sampler): """Sequential Monte Carlo ABC sampler""" - def __init__(self, model, discrepancy=None, outputs=None, **kwargs): - model, discrepancy = self._resolve_model(model, discrepancy) + def __init__(self, model, discrepancy_name=None, output_names=None, **kwargs): + model, discrepancy_name = self._resolve_model(model, discrepancy_name) # Add the prior pdf nodes to the model model = model.copy() - pdf = augmenter.add_pdf_nodes(model)[0] - outputs = self._compose_outputs(outputs, model.parameters, discrepancy, pdf) + pdf_name = augmenter.add_pdf_nodes(model)[0] - super(SMC, self).__init__(model, outputs, **kwargs) + output_names = [discrepancy_name] + model.parameter_names + [pdf_name] + \ + (output_names or []) - self.discrepancy = discrepancy - self.prior_pdf = pdf + super(SMC, self).__init__(model, output_names, **kwargs) + + self.discrepancy_name = discrepancy_name + self.prior_pdf = pdf_name self.state['round'] = 0 self._populations = [] self._rejection = None @@ -649,19 +634,8 @@ def set_objective(self, n_samples, thresholds): def extract_result(self): pop = self._extract_population() - - result = ResultSMC(method_name="SMC-ABC", - outputs=pop.outputs, - parameter_names=self.parameters, - discrepancy_name=self.discrepancy, - threshold=self.state['threshold'], - n_sim=self.state['n_sim'], - accept_rate=self.state['accept_rate'], - seed=self.seed, - populations=self._populations.copy() + [pop] - ) - - return result + return SmcSample(outputs=pop.outputs, populations=self._populations.copy() + [pop], + **self._extract_result_kwargs()) def update(self, batch, batch_index): self._rejection.update(batch, batch_index) @@ -684,7 +658,7 @@ def prepare_new_batch(self, batch_index): # Sample from the proposal params = GMDistribution.rvs(*self._gm_params, size=self.batch_size) # TODO: support vector parameter nodes - batch = {p:params[:,i] for i, p in enumerate(self.parameters)} + batch = {p:params[:,i] for i, p in enumerate(self.parameter_names)} return batch def _new_round(self): @@ -692,8 +666,8 @@ def _new_round(self): logger.info('%s Starting round %d %s' % (dashes, self.state['round'], dashes)) self._rejection = Rejection(self.model, - discrepancy=self.discrepancy, - outputs=self.outputs, + discrepancy_name=self.discrepancy_name, + output_names=self.output_names, batch_size=self.batch_size, seed=self.seed, max_parallel_batches=self.max_parallel_batches) @@ -711,12 +685,11 @@ def _extract_population(self): return pop def _compute_weights_and_cov(self, pop): - samples = pop.outputs - params = np.column_stack(tuple([samples[p] for p in self.parameters])) + params = np.column_stack(tuple([pop.outputs[p] for p in self.parameter_names])) if self._populations: q_densities = GMDistribution.pdf(params, *self._gm_params) - w = samples[self.prior_pdf] / q_densities + w = pop.outputs[self.prior_pdf] / q_densities else: w = np.ones(pop.n_samples) @@ -742,7 +715,7 @@ def _update_objective(self): @property def _gm_params(self): pop_ = self._populations[-1] - params_ = np.column_stack(tuple([pop_.samples[p] for p in self.parameters])) + params_ = np.column_stack(tuple([pop_.samples[p] for p in self.parameter_names])) return params_, pop_.cov, pop_.weights @property @@ -750,17 +723,17 @@ def current_population_threshold(self): return self.objective['thresholds'][self.state['round']] -class BayesianOptimization(InferenceMethod): +class BayesianOptimization(ParameterInference): """Bayesian Optimization of an unknown target function.""" - def __init__(self, model, target=None, outputs=None, batch_size=1, - initial_evidence=None, update_interval=10, bounds=None, target_model=None, + def __init__(self, model, target_name=None, batch_size=1, initial_evidence=None, + update_interval=10, bounds=None, target_model=None, acquisition_method=None, acq_noise_cov=0, **kwargs): """ Parameters ---------- model : ElfiModel or NodeReference - target : str or NodeReference + target_name : str or NodeReference Only needed if model is an ElfiModel target_model : GPyRegression, optional acquisition_method : Acquisition, optional @@ -780,14 +753,14 @@ def __init__(self, model, target=None, outputs=None, batch_size=1, Exploration rate of the acquisition method """ - model, self.target = self._resolve_model(model, target) - outputs = self._compose_outputs(outputs, model.parameters, self.target) - - super(BayesianOptimization, self).\ - __init__(model, outputs=outputs, batch_size=batch_size, **kwargs) + model, target_name = self._resolve_model(model, target_name) + output_names = [target_name] + model.parameter_names + super(BayesianOptimization, self).__init__(model, output_names, + batch_size=batch_size, **kwargs) + self.target_name = target_name target_model = \ - target_model or GPyRegression(len(self.model.parameters), bounds=bounds) + target_model or GPyRegression(len(self.model.parameter_names), bounds=bounds) # Some sensibility limit for starting GP regression n_initial_required = max(10, 2**target_model.input_dim + 1) @@ -797,22 +770,21 @@ def __init__(self, model, target=None, outputs=None, batch_size=1, initial_evidence = n_initial_required elif not isinstance(initial_evidence, int): # Add precomputed batch data - params = self._to_array(initial_evidence, self.parameters) - target_model.update(params, initial_evidence[self.target]) + params = self._to_array(initial_evidence, self.parameter_names) + target_model.update(params, initial_evidence[self.target_name]) initial_evidence = len(params) self._n_precomputed = initial_evidence if initial_evidence < n_initial_required: raise ValueError('Need at least {} initialization points'.format(n_initial_required)) - # TODO: check if this can be removed if initial_evidence % self.batch_size != 0: - raise ValueError('Initial evidence must be divisible by the batch size') + raise ValueError('Number of initial evidence must be divisible by the batch size') + # TODO: check the case when there is no prior in the model self.acquisition_method = acquisition_method or \ LCBSC(target_model, prior=ModelPrior(self.model), noise_cov=acq_noise_cov, seed=self.seed) - # TODO: move some of these to objective self.n_evidence = initial_evidence self.target_model = target_model @@ -835,22 +807,25 @@ def extract_result(self): self.target_model.bounds) param_hat = {} - for i, p in enumerate(self.model.parameters): + for i, p in enumerate(self.model.parameter_names): # Preserve as array param_hat[p] = param[i] - return dict(samples=param_hat) + # TODO: add evidence to outputs + return OptimizationResult(x=param_hat, + outputs=[], + **self._extract_result_kwargs()) def update(self, batch, batch_index): """Update the GP regression model of the target node. """ self.state['pending'].pop(batch_index, None) - params = self._to_array(batch, self.parameters) - self._report_batch(batch_index, params, batch[self.target]) + params = self._to_array(batch, self.parameter_names) + self._report_batch(batch_index, params, batch[self.target_name]) optimize = self._should_optimize() - self.target_model.update(params, batch[self.target], optimize) + self.target_model.update(params, batch[self.target_name], optimize) if optimize: self.state['last_update'] = self.target_model.n_evidence @@ -863,12 +838,12 @@ def prepare_new_batch(self, batch_index): return pending_params = self._to_array(list(self.state['pending'].values()), - self.parameters) + self.parameter_names) t = self.batches.total - int(self.n_initial_evidence / self.batch_size) new_param = self.acquisition_method.acquire(self.batch_size, pending_params, t) # TODO: implement self._to_batch method? - batch = {p: new_param[:,i] for i, p in enumerate(self.parameters)} + batch = {p: new_param[:,i] for i, p in enumerate(self.parameter_names)} self.state['pending'][batch_index] = batch return batch @@ -876,7 +851,7 @@ def prepare_new_batch(self, batch_index): # TODO: use state dict @property def _n_submitted_evidence(self): - return self.batches.total*self.batch_size + return self.batches.total * self.batch_size @property def _allow_submit(self): @@ -899,8 +874,6 @@ def _report_batch(self, batch_index, params, distances): logger.debug(str) def plot_state(self, **options): - # TODO: Refactor - # Plot the GP surface f = plt.gcf() if len(f.axes) < 2: @@ -911,7 +884,7 @@ def plot_state(self, **options): # Draw the GP surface visin.draw_contour(gp.predict_mean, gp.bounds, - self.parameters, + self.parameter_names, title='GP target surface', points = gp._gp.X, axes=f.axes[0], **options) @@ -936,7 +909,7 @@ def plot_state(self, **options): # Draw the acquisition surface visin.draw_contour(acq, gp.bounds, - self.parameters, + self.parameter_names, title='Acquisition surface', points = None, axes=f.axes[1], **options) @@ -959,7 +932,7 @@ def plot_discrepancy(self, axes=None, **kwargs): for ii in range(n_plots): axes[ii].scatter(self.target_model._gp.X[:, ii], self.target_model._gp.Y[:, 0]) - axes[ii].set_xlabel(self.parameters[ii]) + axes[ii].set_xlabel(self.parameter_names[ii]) axes[0].set_ylabel('Discrepancy') @@ -983,16 +956,19 @@ class BOLFI(BayesianOptimization): """ - def fit(self, *args, **kwargs): - """Fit the surrogate model (e.g. Gaussian process) to generate a regression - model between the priors and the resulting discrepancy. - - + def fit(self, n_evidence, threshold=None): + """Fit the surrogate model (e.g. Gaussian process) to generate a GP regression + model for the discrepancy given the parameters. """ logger.info("BOLFI: Fitting the surrogate model...") - self.infer(*args, **kwargs) - def infer_posterior(self, threshold=None): + if n_evidence is None: + raise ValueError('You must specify the number of evidence (n_evidence) for the fitting') + + self.infer(n_evidence) + return self.extract_posterior(threshold) + + def extract_posterior(self, threshold=None): """Returns an object representing the approximate posterior based on surrogate model regression. @@ -1006,13 +982,12 @@ def infer_posterior(self, threshold=None): posterior : elfi.methods.posteriors.BolfiPosterior """ if self.state['n_batches'] == 0: - self.fit() + raise ValueError('Model is not fitted yet, please see the `fit` method.') return BolfiPosterior(self.target_model, threshold=threshold, prior=ModelPrior(self.model)) - def sample(self, n_samples, warmup=None, n_chains=4, threshold=None, initials=None, - algorithm='nuts', **kwargs): + algorithm='nuts', n_evidence=None, **kwargs): """Sample the posterior distribution of BOLFI, where the likelihood is defined through the cumulative density function of standard normal distribution: @@ -1038,14 +1013,20 @@ def sample(self, n_samples, warmup=None, n_chains=4, threshold=None, initials=No Initial values for the sampled parameters for each chain. Defaults to best evidence points. algorithm : string, optional Sampling algorithm to use. Currently only 'nuts' is supported. + n_evidence : int + If the regression model is not fitted yet, specify the amount of evidence Returns ------- np.array """ + + if self.state['n_batches'] == 0: + self.fit(n_evidence) + #TODO: other MCMC algorithms - posterior = self.infer_posterior(threshold) + posterior = self.extract_posterior(threshold) warmup = warmup or n_samples // 2 # Unless given, select the evidence points with smallest discrepancy @@ -1069,7 +1050,6 @@ def sample(self, n_samples, warmup=None, n_chains=4, threshold=None, initials=No posterior.gradient_logpdf, n_adapt=warmup, seed=seed, **kwargs)) # get results from completed tasks or run sampling (client-specific) - # TODO: support async chains = [] for id in tasks_ids: chains.append(self.client.get(id)) @@ -1078,14 +1058,14 @@ def sample(self, n_samples, warmup=None, n_chains=4, threshold=None, initials=No print("{} chains of {} iterations acquired. Effective sample size and Rhat for each parameter:" .format(n_chains, n_samples)) - for ii, node in enumerate(self.parameters): + for ii, node in enumerate(self.parameter_names): print(node, mcmc.eff_sample_size(chains[:, :, ii]), mcmc.gelman_rubin(chains[:, :, ii])) self.target_model.is_sampling = False - return ResultBOLFI(method_name='BOLFI', + return BolfiSample(method_name='BOLFI', chains=chains, - parameter_names=self.parameters, + parameter_names=self.parameter_names, warmup=warmup, threshold=float(posterior.threshold), n_sim=self.state['n_sim'], diff --git a/elfi/methods/posteriors.py b/elfi/methods/posteriors.py index 0c9d5a8e..dfd6647d 100644 --- a/elfi/methods/posteriors.py +++ b/elfi/methods/posteriors.py @@ -136,7 +136,7 @@ def _unnormalized_loglikelihood(self, x): return logpdf mean, var = self.model.predict(x) - logpdf[logi] = ss.norm.logcdf(self.threshold, mean, np.sqrt(var)) + logpdf[logi] = ss.norm.logcdf(self.threshold, mean, np.sqrt(var)).squeeze() if ndim == 0 or (ndim==1 and self.dim > 1): logpdf = logpdf[0] diff --git a/elfi/methods/results.py b/elfi/methods/results.py index 19ae53ae..28f72706 100644 --- a/elfi/methods/results.py +++ b/elfi/methods/results.py @@ -12,41 +12,71 @@ logger = logging.getLogger(__name__) -""" -Implementations related to results and post-processing. -""" - +class ParameterInferenceResult: + def __init__(self, method_name, outputs, parameter_names, **kwargs): + """ -class Result(object): - """Container for results from ABC methods. Allows intuitive syntax for plotting etc. + Parameters + ---------- + method_name : string + Name of inference method. + outputs : dict + Dictionary with outputs from the nodes, e.g. samples. + parameter_names : list + Names of the parameter nodes + **kwargs + Any other information from the inference algorithm, usually from it's state. - Parameters - ---------- - method_name : string - Name of inference method. - outputs : dict - Dictionary with values as np.arrays. May contain more keys than just the names of priors. - parameter_names : list : list of strings - List of names in the outputs dict that refer to model parameters. - discrepancy_name : string, optional - Name of the discrepancy in outputs. - """ - # TODO: infer these from state? - def __init__(self, method_name, outputs, parameter_names, discrepancy_name=None, **kwargs): + """ self.method_name = method_name self.outputs = outputs.copy() - self.samples = OrderedDict() + self.parameter_names = parameter_names + self.meta = kwargs - for n in parameter_names: - self.samples[n] = outputs[n] - if discrepancy_name is not None: - self.discrepancy = outputs[discrepancy_name] - self.n_samples = len(outputs[parameter_names[0]]) - self.n_params = len(parameter_names) +class OptimizationResult(ParameterInferenceResult): + def __init__(self, x, **kwargs): + """ - # store arbitrary keyword arguments here - self.meta = kwargs + Parameters + ---------- + x + The optimized parameters + **kwargs + See `ParameterInferenceResult` + + """ + super(OptimizationResult, self).__init__(**kwargs) + self.x = x + + +# TODO: refactor +class Sample(ParameterInferenceResult): + """Sampling results from the methods. + + """ + def __init__(self, method_name, outputs, parameter_names, discrepancy_name=None, + **kwargs): + """ + + Parameters + ---------- + discrepancy_name : string, optional + Name of the discrepancy in outputs. + **kwargs + Other meta information for the result + """ + super(Sample, self).__init__(method_name=method_name, outputs=outputs, + parameter_names=parameter_names, **kwargs) + + self.samples = OrderedDict() + for n in self.parameter_names: + self.samples[n] = self.outputs[n] + if discrepancy_name is not None: + self.discrepancy = self.outputs[discrepancy_name] + + self.n_samples = len(self.outputs[self.parameter_names[0]]) + self.n_params = len(self.parameter_names) def __getattr__(self, item): """Allows more convenient access to items under self.meta. @@ -159,11 +189,11 @@ def plot_pairs(self, selector=None, bins=20, axes=None, **kwargs): return vis.plot_pairs(self.samples, selector, bins, axes, **kwargs) -class ResultSMC(Result): +class SmcSample(Sample): """Container for results from SMC-ABC. """ def __init__(self, *args, **kwargs): - super(ResultSMC, self).__init__(*args, **kwargs) + super(SmcSample, self).__init__(*args, **kwargs) self.n_populations = len(self.populations) @property @@ -243,7 +273,7 @@ def plot_pairs_all_populations(self, selector=None, bins=20, axes=None, **kwargs plt.suptitle("Population {}".format(ii), fontsize=fontsize) -class ResultBOLFI(Result): +class BolfiSample(Sample): """Container for results from BOLFI. Parameters @@ -265,8 +295,10 @@ def __init__(self, method_name, chains, parameter_names, warmup, **kwargs): concatenated = warmed_up.reshape((-1,) + shape[2:]) outputs = dict(zip(parameter_names, concatenated.T)) - super(ResultBOLFI, self).__init__(method_name=method_name, outputs=outputs, parameter_names=parameter_names, - chains=chains, n_chains=n_chains, warmup=warmup, **kwargs) + super(BolfiSample, self).__init__(method_name=method_name, outputs=outputs, + parameter_names=parameter_names, + chains=chains, n_chains=n_chains, warmup=warmup, + **kwargs) def plot_traces(self, selector=None, axes=None, **kwargs): return vis.plot_traces(self, selector, axes, **kwargs) diff --git a/elfi/methods/utils.py b/elfi/methods/utils.py index 02b1317f..7692646b 100644 --- a/elfi/methods/utils.py +++ b/elfi/methods/utils.py @@ -179,11 +179,11 @@ def __init__(self, model): Parameters ---------- - model : elfi.ElfiModel + model : ElfiModel """ model = model.copy() - self.parameters = model.parameters - self.dim = len(self.parameters) + self.parameter_names = model.parameter_names + self.dim = len(self.parameter_names) self.client = Client() self.context = ComputationContext() @@ -192,7 +192,7 @@ def __init__(self, model): self._pdf_node = augmenter.add_pdf_nodes(model, log=False)[0] self._logpdf_node = augmenter.add_pdf_nodes(model, log=True)[0] - self._rvs_net = self.client.compile(model.source_net, outputs=self.parameters) + self._rvs_net = self.client.compile(model.source_net, outputs=self.parameter_names) self._pdf_net = self.client.compile(model.source_net, outputs=self._pdf_node) self._logpdf_net = self.client.compile(model.source_net, outputs=self._logpdf_node) @@ -204,7 +204,7 @@ def rvs(self, size=None, random_state=None): loaded_net = self.client.load_data(self._rvs_net, self.context, batch_index=0) batch = self.client.compute(loaded_net) - rvs = np.column_stack([batch[p] for p in self.parameters]) + rvs = np.column_stack([batch[p] for p in self.parameter_names]) if self.dim == 1: rvs = rvs.reshape(size or 1) @@ -264,4 +264,4 @@ def gradient_logpdf(self, x): return grads def _to_batch(self, x): - return {p: x[:, i] for i, p in enumerate(self.parameters)} + return {p: x[:, i] for i, p in enumerate(self.parameter_names)} diff --git a/elfi/model/augmenter.py b/elfi/model/augmenter.py index f0cec9e8..142ada5a 100644 --- a/elfi/model/augmenter.py +++ b/elfi/model/augmenter.py @@ -28,7 +28,7 @@ def add_pdf_gradient_nodes(model, log=False, nodes=None): """ - nodes = nodes or model.parameters + nodes = nodes or model.parameter_names gradattr = 'gradient_pdf' if log is False else 'gradient_logpdf' grad_nodes = _add_distribution_nodes(model, nodes, gradattr) @@ -59,7 +59,7 @@ def add_pdf_nodes(model, joint=True, log=False, nodes=None): names depending on the `joint` argument. """ - nodes = nodes or model.parameters + nodes = nodes or model.parameter_names pdfattr = 'pdf' if log is False else 'logpdf' pdfs = _add_distribution_nodes(model, nodes, pdfattr) diff --git a/elfi/model/elfi_model.py b/elfi/model/elfi_model.py index 950382c5..33f65826 100644 --- a/elfi/model/elfi_model.py +++ b/elfi/model/elfi_model.py @@ -284,12 +284,12 @@ def observed(self): return self.computation_context.observed @property - def parameters(self): - """A list of model parameters in an alphabetical order.""" + def parameter_names(self): + """A list of model parameter names in an alphabetical order.""" return sorted([n for n in self.nodes if '_parameter' in self.get_state(n)]) - @parameters.setter - def parameters(self, parameters): + @parameter_names.setter + def parameter_names(self, parameter_names): """Set the model parameter nodes. For each node name in parameters, the corresponding node will be marked as being a @@ -297,23 +297,23 @@ def parameters(self, parameters): Parameters ---------- - parameters : iterable - Iterable of parameter names + parameter_names : list + A list of parameter names Returns ------- None """ - parameters = set(parameters) + parameter_names = set(parameter_names) for n in self.nodes: state = self.get_state(n) - if n in parameters: - parameters.remove(n) + if n in parameter_names: + parameter_names.remove(n) state['_parameter'] = True else: if '_parameter' in state: state.pop('_parameter') - if len(parameters) > 0: - raise ValueError('Parameters {} not found from the model'.format(parameters)) + if len(parameter_names) > 0: + raise ValueError('Parameters {} not found from the model'.format(parameter_names)) def __copy__(self): kopy = super(ElfiModel, self).__copy__(set_current=False) @@ -776,12 +776,13 @@ def __init__(self, discrepancy, *parents, **kwargs): discrepancy : callable Signature of the discrepancy function is of the form: `discrepancy(summary_1, summary_2, ..., observed)`, where summaries are - arrays containing `batch_size` simulated values. - - The callable should return a vector of discrepancies between the simulated - summaries and the observed summaries. - observed : tuple - tuple (observed_summary_1, observed_summary_2, ...) + arrays containing `batch_size` simulated values and observed is a tuple + (observed_summary_1, observed_summary_2, ...). The callable object should + return a vector of discrepancies between the simulated summaries and the + observed summaries. + *parents + Typically the summaries for the discrepancy function. + **kwargs See Also -------- diff --git a/tests/functional/test_inference.py b/tests/functional/test_inference.py index c3c8252e..9bfd1c84 100644 --- a/tests/functional/test_inference.py +++ b/tests/functional/test_inference.py @@ -107,20 +107,20 @@ def test_BOLFI(): bolfi = elfi.BOLFI(log_d, initial_evidence=20, update_interval=10, batch_size=5, bounds=[(-2,2), (-1, 1)], acq_noise_cov=.1) n = 300 - res = bolfi.infer(n) - assert bolfi.target_model.n_evidence == n + res = bolfi.infer(300) + assert bolfi.target_model.n_evidence == 300 acq_x = bolfi.target_model._gp.X # check_inference_with_informative_data(res, 1, true_params, error_bound=.2) - assert np.abs(res['samples']['t1'] - true_params['t1']) < 0.2 - assert np.abs(res['samples']['t2'] - true_params['t2']) < 0.2 + assert np.abs(res.x['t1'] - true_params['t1']) < 0.2 + assert np.abs(res.x['t2'] - true_params['t2']) < 0.2 # Test that you can continue the inference where we left off res = bolfi.infer(n+10) assert bolfi.target_model.n_evidence == n+10 assert np.array_equal(bolfi.target_model._gp.X[:n,:], acq_x) - post = bolfi.infer_posterior() + post = bolfi.extract_posterior() # TODO: make cleaner. post_ml = minimize(post._neg_unnormalized_loglikelihood, post._gradient_neg_unnormalized_loglikelihood, diff --git a/tests/functional/test_simulation_reuse.py b/tests/functional/test_simulation_reuse.py index 61589f56..272864d7 100644 --- a/tests/functional/test_simulation_reuse.py +++ b/tests/functional/test_simulation_reuse.py @@ -10,7 +10,7 @@ @pytest.mark.parametrize('sleep_model', [.2], indirect=['sleep_model']) def test_pool(sleep_model): # Add nodes to the pool - pool = elfi.OutputPool(outputs=sleep_model.parameters + ['slept', 'summary', 'd']) + pool = elfi.OutputPool(outputs=sleep_model.parameter_names + ['slept', 'summary', 'd']) rej = elfi.Rejection(sleep_model['d'], batch_size=5, pool=pool) quantile = .25 diff --git a/tests/unit/test_bo.py b/tests/unit/test_bo.py index c505c64b..17ebc08c 100644 --- a/tests/unit/test_bo.py +++ b/tests/unit/test_bo.py @@ -15,7 +15,7 @@ def test_BO(ma2): bo = elfi.BayesianOptimization(log_d, initial_evidence=res_init.outputs, update_interval=10, batch_size=5, - bounds=[(-2,2)]*len(ma2.parameters)) + bounds=[(-2,2)]*len(ma2.parameter_names)) assert bo.target_model.n_evidence == n_init assert bo.n_evidence == n_init assert bo._n_precomputed == n_init diff --git a/tests/unit/test_document_examples.py b/tests/unit/test_document_examples.py new file mode 100644 index 00000000..9f1b6034 --- /dev/null +++ b/tests/unit/test_document_examples.py @@ -0,0 +1,78 @@ +""" +This file contains tests for some of the examples used in the documentation that +are not automatically produced from the notebooks. + +Note that if you change anything in this file (e.g. imports), you should change +the documentation accordingly. For this reason the imports and class definitions +are inside their respective functions. +""" + + +def test_implementing_new_algorithm(): + import numpy as np + + from elfi.methods.parameter_inference import ParameterInference + from elfi.methods.results import Sample + + import elfi.examples.ma2 as ma2 + + class CustomMethod(ParameterInference): + def __init__(self, model, discrepancy_name, threshold, **kwargs): + # Create a name list of nodes whose outputs we wish to receive + output_names = [discrepancy_name] + model.parameter_names + super(CustomMethod, self).__init__(model, output_names, **kwargs) + + self.threshold = threshold + self.discrepancy_name = discrepancy_name + + # Prepare lists to push the filtered outputs into + self.state['filtered_outputs'] = {name: [] for name in output_names} + + def set_objective(self, n_sim): + self.objective['n_sim'] = n_sim + + def update(self, batch, batch_index): + super(CustomMethod, self).update(batch, batch_index) + + # Make a filter mask (logical numpy array) from the distance array + filter_mask = batch[self.discrepancy_name] <= self.threshold + + # Append the filtered parameters to their lists + for name in self.output_names: + values = batch[name] + self.state['filtered_outputs'][name].append(values[filter_mask]) + + def extract_result(self): + filtered_outputs = self.state['filtered_outputs'] + outputs = {name: np.concatenate(filtered_outputs[name]) for name in self.output_names} + + return Sample( + method_name='CustomMethod', + outputs=outputs, + parameter_names=self.parameter_names, + discrepancy_name=self.discrepancy_name, + n_sim=self.state['n_sim'], + threshold=self.threshold + ) + + # Below is from the part where we demonstrate iterative advancing + + # Run it + m = ma2.get_model() + custom_method = CustomMethod(m, 'd', threshold=.1, batch_size=1000) + + # Continue inference from the previous state (with n_sim=2000) + custom_method.infer(n_sim=4000) + + # Or use it iteratively + custom_method.set_objective(n_sim=6000) + + custom_method.iterate() + assert custom_method.finished == False + + # Investigate the current state + custom_method.extract_result() + + custom_method.iterate() + assert custom_method.finished + custom_method.extract_result() diff --git a/tests/unit/test_elfi_model.py b/tests/unit/test_elfi_model.py index 060b3301..032d1008 100644 --- a/tests/unit/test_elfi_model.py +++ b/tests/unit/test_elfi_model.py @@ -106,13 +106,13 @@ def test_become(self, ma2): assert set(nodes) == set(nodes2) def test_become_with_priors(self, ma2): - parameters = ma2.parameters.copy() + parameters = ma2.parameter_names.copy() parent_names = ma2.parent_names('t1') ma2['t1'].become(elfi.Prior('uniform', 0, model=ma2)) # Test that parameters are preserved - assert parameters == ma2.parameters + assert parameters == ma2.parameter_names # Test that hidden nodes are removed for name in parent_names: diff --git a/tests/unit/test_examples.py b/tests/unit/test_examples.py index 2f761db4..b742144a 100644 --- a/tests/unit/test_examples.py +++ b/tests/unit/test_examples.py @@ -16,6 +16,10 @@ def test_bdm(recwarn): assert os.path.isfile(cpp_path + '/bdm') + # Remove the executable if it already exists + if os.path.isfile('bdm'): + os.system('rm bdm') + with pytest.warns(RuntimeWarning): bdm = ee.bdm.get_model() diff --git a/tests/unit/test_methods.py b/tests/unit/test_methods.py index 3a41fea8..2eae5f59 100644 --- a/tests/unit/test_methods.py +++ b/tests/unit/test_methods.py @@ -4,14 +4,14 @@ import elfi -from elfi.methods.methods import InferenceMethod +from elfi.methods.parameter_inference import ParameterInference def test_no_model_parameters(simple_model): - simple_model.parameters = [] + simple_model.parameter_names = [] with pytest.raises(Exception): - InferenceMethod(simple_model, []) + ParameterInference(simple_model, []) @pytest.mark.usefixtures('with_all_clients') @@ -44,7 +44,7 @@ def test_BOLFI_short(ma2, distribution_test): assert bolfi.target_model.n_evidence == n+5 assert np.array_equal(bolfi.target_model._gp.X[:n,:], acq_x) - post = bolfi.infer_posterior() + post = bolfi.extract_posterior() distribution_test(post, rvs=(acq_x[0,:], acq_x[1:2,:], acq_x[2:4,:])) diff --git a/tests/unit/test_results.py b/tests/unit/test_results.py index 3dec7520..72e488e4 100644 --- a/tests/unit/test_results.py +++ b/tests/unit/test_results.py @@ -10,12 +10,13 @@ def test_Result(): distance_name = 'dist' samples = [np.random.random(n_samples), np.random.random(n_samples), np.random.random(n_samples)] outputs = dict(zip(parameter_names + [distance_name], samples)) - result = Result(method_name="TestRes", + result = Sample(method_name="TestRes", outputs=outputs, parameter_names=parameter_names, discrepancy_name=distance_name, something='x', - something_else='y' + something_else='y', + n_sim=0, ) assert result.method_name == "TestRes" @@ -40,12 +41,13 @@ def test_ResultBOLFI(): parameter_names = ['a', 'b'] chains = np.random.random((n_chains, n_iters, len(parameter_names))) - result = ResultBOLFI(method_name="TestRes", + result = BolfiSample(method_name="TestRes", chains=chains, parameter_names=parameter_names, warmup=warmup, something='x', - something_else='y' + something_else='y', + n_sim=0, ) assert result.method_name == "TestRes" From e5136fc304d45c6cd445cc5b08f571034a24c572 Mon Sep 17 00:00:00 2001 From: Jarno Lintusaari Date: Tue, 20 Jun 2017 13:30:40 +0300 Subject: [PATCH 13/38] Numeric gradient improvements (#177) - Add ability to change the stepsize - Add ability to specify the stepsizes per dimension basis - Removed warnings from -inf resulting from negative logpdf --- elfi/methods/utils.py | 37 ++++++++++++++++++++++++++++++------- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/elfi/methods/utils.py b/elfi/methods/utils.py index 7692646b..2444bf04 100644 --- a/elfi/methods/utils.py +++ b/elfi/methods/utils.py @@ -1,4 +1,5 @@ import logging +import warnings import numpy as np import scipy.stats as ss @@ -135,7 +136,7 @@ def _normalize_params(means, weights): return means, weights -def numgrad(fn, x, h=0.00001): +def numgrad(fn, x, h=None, replace_neg_inf=True): """Naive numeric gradient implementation for scalar valued functions. Parameters @@ -143,13 +144,20 @@ def numgrad(fn, x, h=0.00001): fn x : np.ndarray A single point in 1d vector - h + h : float or list + Stepsize or stepsizes for the dimensions + replace_neg_inf : bool + Replace neg inf fn values with gradient 0 (useful for logpdf gradients) Returns ------- - + grad : np.ndarray + 1D gradient vector """ + h = 0.00001 if h is None else h + h = np.asanyarray(h).reshape(-1) + x = np.asanyarray(x, dtype=np.float).reshape(-1) dim = len(x) X = np.zeros((dim*3, dim)) @@ -162,9 +170,12 @@ def numgrad(fn, x, h=0.00001): f = fn(X) f = f.reshape((3, dim)) - fgrad = np.gradient(f, h, axis=0) + if replace_neg_inf: + if np.any(np.isneginf(f)): + return np.zeros(dim) - return fgrad[1, :] + grad = np.gradient(f, *h, axis=0) + return grad[1, :] # TODO: check that there are no latent variables in parameter parents. @@ -245,7 +256,19 @@ def _evaluate_pdf(self, x, log=False): def gradient_pdf(self, x): raise NotImplementedError - def gradient_logpdf(self, x): + def gradient_logpdf(self, x, stepsize=None): + """ + + Parameters + ---------- + x + stepsize : float or list + Stepsize or stepsizes for the dimensions + + Returns + ------- + + """ x = np.asanyarray(x) ndim = x.ndim x = x.reshape((-1, self.dim)) @@ -254,7 +277,7 @@ def gradient_logpdf(self, x): for i in range(len(grads)): xi = x[i] - grads[i] = numgrad(self.logpdf, xi) + grads[i] = numgrad(self.logpdf, xi, h=stepsize) grads[np.isinf(grads)] = 0 grads[np.isnan(grads)] = 0 From 193cd85501933f5b4a89cd265b03a619b6c5f4b8 Mon Sep 17 00:00:00 2001 From: Henri Vuollekoski Date: Tue, 20 Jun 2017 17:27:40 +0300 Subject: [PATCH 14/38] Change acquisition noise to truncnorm if the cov matrix is diagonal. (#178) --- elfi/methods/bo/acquisition.py | 33 ++++++++++++++++------ tests/unit/test_bo.py | 50 ++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 9 deletions(-) diff --git a/elfi/methods/bo/acquisition.py b/elfi/methods/bo/acquisition.py index 53dee809..d5a127c1 100644 --- a/elfi/methods/bo/acquisition.py +++ b/elfi/methods/bo/acquisition.py @@ -1,7 +1,7 @@ import logging import numpy as np -from scipy.stats import uniform, multivariate_normal +from scipy.stats import uniform, multivariate_normal, truncnorm from elfi.methods.bo.utils import minimize @@ -42,8 +42,15 @@ def __init__(self, model, prior=None, n_inits=10, max_opt_iters=1000, noise_cov= if isinstance(noise_cov, (float, int)): noise_cov = np.eye(self.model.input_dim) * noise_cov + elif noise_cov.ndim == 1: + noise_cov = np.diag(noise_cov) self.noise_cov = noise_cov + # check if covariance is diagonal + self._diagonal_cov = np.all(np.diag(np.diag(self.noise_cov)) == self.noise_cov) + if self._diagonal_cov: + self._noise_sigma = np.sqrt(np.diag(self.noise_cov)) + self.random_state = np.random.RandomState(seed) def evaluate(self, x, t=None): @@ -57,7 +64,7 @@ def evaluate(self, x, t=None): """ raise NotImplementedError - def evaluate_grad(self, x, t=None): + def evaluate_gradient(self, x, t=None): """Evaluates the gradient of acquisition function value at 'x'. Parameters @@ -92,17 +99,25 @@ def acquire(self, n_values, pending_locations=None, t=None): logger.debug('Acquiring {} values'.format(n_values)) obj = lambda x: self.evaluate(x, t) - grad_obj = lambda x: self.evaluate_grad(x, t) + grad_obj = lambda x: self.evaluate_gradient(x, t) minloc, minval = minimize(obj, grad_obj, self.model.bounds, self.prior, self.n_inits, self.max_opt_iters) x = np.tile(minloc, (n_values, 1)) # add some noise for more efficient exploration - x += multivariate_normal.rvs(cov=self.noise_cov, size=n_values, random_state=self.random_state) \ - .reshape((n_values, -1)) + if self._diagonal_cov: + for ii in range(self.model.input_dim): + bounds_a = (self.model.bounds[ii][0] - x[:, ii]) / self._noise_sigma[ii] + bounds_b = (self.model.bounds[ii][1] - x[:, ii]) / self._noise_sigma[ii] + x[:, ii] = truncnorm.rvs(bounds_a, bounds_b, loc=x[:, ii], scale=self._noise_sigma[ii], + size=n_values, random_state=self.random_state) + + else: + x += multivariate_normal.rvs(cov=self.noise_cov, size=n_values, random_state=self.random_state) \ + .reshape((n_values, -1)) - # make sure the acquired points stay within bounds - for ii in range(self.model.input_dim): - x[:, ii] = np.clip(x[:, ii], *self.model.bounds[ii]) + # make sure the acquired points stay within bounds simply by clipping + for ii in range(self.model.input_dim): + x[:, ii] = np.clip(x[:, ii], *self.model.bounds[ii]) return x @@ -162,7 +177,7 @@ def evaluate(self, x, t=None): mean, var = self.model.predict(x, noiseless=True) return mean - np.sqrt(self._beta(t) * var) - def evaluate_grad(self, x, t=None): + def evaluate_gradient(self, x, t=None): """Gradient of the lower confidence bound selection criterion. Parameters diff --git a/tests/unit/test_bo.py b/tests/unit/test_bo.py index 17ebc08c..154cab24 100644 --- a/tests/unit/test_bo.py +++ b/tests/unit/test_bo.py @@ -38,3 +38,53 @@ def test_BO(ma2): assert bo.n_initial_evidence == n_init assert np.array_equal(bo.target_model._gp.X[:n_init, 0], res_init.samples_list[0]) + + +def test_acquisition(): + n_params = 2 + n = 10 + n2 = 5 + bounds = [[-2, 3], [5, 6]] + target_model = elfi.methods.bo.gpy_regression.GPyRegression(n_params, bounds=bounds) + x1 = np.random.uniform(*bounds[0], n) + x2 = np.random.uniform(*bounds[1], n) + x = np.column_stack((x1, x2)) + y = np.random.rand(n) + target_model.update(x, y) + + # check acquisition without noise + acq_noise_cov = 0 + t = 1 + acquisition_method = elfi.methods.bo.acquisition.LCBSC(target_model, noise_cov=acq_noise_cov) + new = acquisition_method.acquire(n2, t=t) + assert np.allclose(new[1:, 0], new[0, 0]) + assert np.allclose(new[1:, 1], new[0, 1]) + + # check acquisition with scalar noise + acq_noise_cov = 2 + t = 1 + acquisition_method = elfi.methods.bo.acquisition.LCBSC(target_model, noise_cov=acq_noise_cov) + new = acquisition_method.acquire(n2, t=t) + assert new.shape == (n2, n_params) + assert np.all((new[:, 0] >= bounds[0][0]) & (new[:, 0] <= bounds[0][1])) + assert np.all((new[:, 1] >= bounds[1][0]) & (new[:, 1] <= bounds[1][1])) + + # check acquisition with diagonal covariance + acq_noise_cov = np.random.uniform(0, 5, size=2) + t = 1 + acquisition_method = elfi.methods.bo.acquisition.LCBSC(target_model, noise_cov=acq_noise_cov) + new = acquisition_method.acquire(n2, t=t) + assert new.shape == (n2, n_params) + assert np.all((new[:, 0] >= bounds[0][0]) & (new[:, 0] <= bounds[0][1])) + assert np.all((new[:, 1] >= bounds[1][0]) & (new[:, 1] <= bounds[1][1])) + + # check acquisition with arbitrary covariance matrix + acq_noise_cov = np.random.rand(n_params, n_params) * 0.5 + acq_noise_cov += acq_noise_cov.T + acq_noise_cov += n_params * np.eye(n_params) + t = 1 + acquisition_method = elfi.methods.bo.acquisition.LCBSC(target_model, noise_cov=acq_noise_cov) + new = acquisition_method.acquire(n2, t=t) + assert new.shape == (n2, n_params) + assert np.all((new[:, 0] >= bounds[0][0]) & (new[:, 0] <= bounds[0][1])) + assert np.all((new[:, 1] >= bounds[1][0]) & (new[:, 1] <= bounds[1][1])) From 56a195477036eb3aa7453639081e5d3cca36e73b Mon Sep 17 00:00:00 2001 From: Henri Vuollekoski Date: Tue, 20 Jun 2017 17:36:40 +0300 Subject: [PATCH 15/38] Implement multiprocessing client (#170) --- elfi/clients/multiprocessing.py | 105 ++++++++++++++++++++++++++++ tests/conftest.py | 3 +- tests/functional/test_randomness.py | 13 +++- 3 files changed, 119 insertions(+), 2 deletions(-) create mode 100644 elfi/clients/multiprocessing.py diff --git a/elfi/clients/multiprocessing.py b/elfi/clients/multiprocessing.py new file mode 100644 index 00000000..e0cfb202 --- /dev/null +++ b/elfi/clients/multiprocessing.py @@ -0,0 +1,105 @@ +import logging +import itertools +import multiprocessing + +from elfi.executor import Executor +import elfi.client + +logger = logging.getLogger(__name__) + + +def set_as_default(): + elfi.client.set_client() + elfi.client.set_default_class(Client) + + +class Client(elfi.client.ClientBase): + """ + Client based on Python's built-in multiprocessing module. + + Parameters + ---------- + num_processes : int, optional + Number of worker processes to use. Defaults to os.cpu_count(). + """ + + def __init__(self, num_processes=None): + self.pool = multiprocessing.Pool(processes=num_processes) + + self.tasks = {} + self._id_counter = itertools.count() + + def apply(self, kallable, *args, **kwargs): + """Adds `kallable(*args, **kwargs)` to the queue of tasks. Returns immediately. + + Parameters + ---------- + kallable : callable + + Returns + ------- + id : int + Number of the queued task. + """ + id = self._id_counter.__next__() + async_res = self.pool.apply_async(kallable, args, kwargs) + self.tasks[id] = async_res + return id + + def apply_sync(self, kallable, *args, **kwargs): + """Calls and returns the result of `kallable(*args, **kwargs)`. + + Parameters + ---------- + kallable : callable + """ + return self.pool.apply(kallable, args, kwargs) + + def get(self, task_id): + """Returns the result from task identified by `task_id` when it arrives. + + Parameters + ---------- + task_id : int + Id of the task whose result to return. + """ + async_result = self.tasks.pop(task_id) + return async_result.get() + + def wait_next(self, task_ids): + for id in task_ids: + return self.get(id) + + def is_ready(self, task_id): + """Return whether task with identifier `task_id` is ready. + + Parameters + ---------- + task_id : int + """ + return self.tasks[task_id].ready() + + def remove_task(self, task_id): + """Remove task with identifier `task_id` from pool. + + Parameters + ---------- + task_id : int + """ + if task_id in self.tasks: + del self.tasks[task_id] + # TODO: also kill the pid? + + def reset(self): + """Stop all worker processes immediately and clear pending tasks. + """ + self.pool.terminate() + self.pool.join() + self.tasks.clear() + + @property + def num_cores(self): + return self.pool._processes # N.B. Not necessarily the number of actual cores. + +# TODO: use import hook instead? https://docs.python.org/3/reference/import.html +set_as_default() diff --git a/tests/conftest.py b/tests/conftest.py index 9d3a5751..2e0cf595 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,6 +9,7 @@ import elfi import elfi.clients.ipyparallel as eipp import elfi.clients.native as native +import elfi.clients.multiprocessing as mp import elfi.examples elfi.clients.native.set_as_default() @@ -27,7 +28,7 @@ def pytest_addoption(parser): @pytest.fixture(scope="session", - params=[native, eipp]) + params=[native, eipp, mp]) def client(request): """Provides a fixture for all the different supported clients """ diff --git a/tests/functional/test_randomness.py b/tests/functional/test_randomness.py index 22ac1e40..38598fd5 100644 --- a/tests/functional/test_randomness.py +++ b/tests/functional/test_randomness.py @@ -7,7 +7,6 @@ from elfi.utils import get_sub_seed -@pytest.mark.usefixtures('with_all_clients') def test_randomness(simple_model): k1 = simple_model['k1'] @@ -17,6 +16,18 @@ def test_randomness(simple_model): assert not np.array_equal(gen1, gen2) +@pytest.mark.usefixtures('with_all_clients') +def test_randomness2(simple_model): + k1 = simple_model['k1'] + + n = 30 + samples1 = elfi.Rejection(simple_model['k1'], batch_size=3).sample(n).samples['k1'] + assert len(np.unique(samples1)) == n + + samples2 = elfi.Rejection(simple_model['k1'], batch_size=3).sample(n).samples['k1'] + assert not np.array_equal(samples1, samples2) + + # If we want to test this with all clients, we need to to set the worker's random state def test_global_random_state_usage(simple_model): n_gen = 10 From b0ec71dff71ee82fe85b52f30bd82ab960e6db38 Mon Sep 17 00:00:00 2001 From: Jarno Lintusaari Date: Tue, 20 Jun 2017 18:39:38 +0300 Subject: [PATCH 16/38] Allow dicts as outputs (#180) * Allow dicts as outputs * Small change --- elfi/executor.py | 21 ++++++++----- elfi/methods/parameter_inference.py | 31 ++++++++++++-------- elfi/methods/results.py | 1 + elfi/model/tools.py | 29 +++++++++--------- elfi/model/utils.py | 2 +- elfi/utils.py | 5 ++++ tests/functional/test_consistency.py | 4 --- tests/functional/test_custom_outputs.py | 39 +++++++++++++++++++++++++ 8 files changed, 92 insertions(+), 40 deletions(-) delete mode 100644 tests/functional/test_consistency.py create mode 100644 tests/functional/test_custom_outputs.py diff --git a/elfi/executor.py b/elfi/executor.py index 86a1f2ab..a1edbe38 100644 --- a/elfi/executor.py +++ b/elfi/executor.py @@ -59,14 +59,22 @@ def execute(cls, G): for node in order: attr = G.node[node] logger.debug("Executing {}".format(node)) + if attr.keys() >= {'operation', 'output'}: - raise ValueError('Generative graph has both op and output present') + raise ValueError('Generative graph has both op and output present for ' + 'node {}'.format(node)) if 'operation' in attr: op = attr['operation'] - G.node[node] = cls._run(op, node, G) + try: + G.node[node] = cls._run(op, node, G) + except Exception as exc: + raise exc.__class__("In executing node '{}': {}." + .format(node, exc)).with_traceback(exc.__traceback__) + elif 'output' not in attr: - raise ValueError('Generative graph has no op or output present') + raise ValueError('Generative graph has no op or output present for node ' + '{}'.format(node)) # Make a result dict based on the requested outputs result = {k:G.node[k]['output'] for k in G.graph['outputs']} @@ -127,11 +135,8 @@ def _run(fn, node, G): args = [a[1] for a in sorted(args, key=itemgetter(0))] - output = fn(*args, **kwargs) - - if not isinstance(output, dict): - output = dict(output=output) - return output + output_dict = {'output': fn(*args, **kwargs)} + return output_dict def nx_constant_topological_sort(G, nbunch=None, reverse=False): diff --git a/elfi/methods/parameter_inference.py b/elfi/methods/parameter_inference.py index 671aa197..927d1a73 100644 --- a/elfi/methods/parameter_inference.py +++ b/elfi/methods/parameter_inference.py @@ -11,6 +11,7 @@ import elfi.methods.mcmc as mcmc import elfi.model.augmenter as augmenter +from elfi.utils import is_array from elfi.loader import get_sub_seed from elfi.methods.bo.acquisition import LCBSC from elfi.methods.bo.gpy_regression import GPyRegression @@ -533,24 +534,30 @@ def extract_result(self): def _init_samples_lazy(self, batch): """Initialize the outputs dict based on the received batch""" samples = {} - e_len = "Node {} output length was {}. It should be equal to the batch size {}." - e_nolen = "Node {} output has no length. It should be equal to the batch size {}." + e_noarr = "Node {} output must be in a numpy array of length {} (batch_size)." + e_len = "Node {} output must be an arraylength was {}. It should be equal to the batch size {}." for node in self.output_names: # Check the requested outputs - try: - if len(batch[node]) != self.batch_size: - raise ValueError(e_len.format(node, len(batch[node]), self.batch_size)) - except TypeError: - raise ValueError(e_nolen.format(node, self.batch_size)) - except KeyError: + if node not in batch: raise KeyError("Did not receive outputs for node {}".format(node)) + nbatch = batch[node] + if not is_array(nbatch): + raise ValueError(e_noarr.format(node, self.batch_size)) + elif len(nbatch) != self.batch_size: + raise ValueError(e_len.format(node, len(nbatch), self.batch_size)) + # Prepare samples - shape = (self.objective['n_samples'] + self.batch_size,) + batch[node].shape[1:] - # FIXME: add the correct dtype from batch. The inf initialization only for the - # distance - samples[node] = np.ones(shape) * np.inf + shape = (self.objective['n_samples'] + self.batch_size,) + nbatch.shape[1:] + dtype = nbatch.dtype + + if node == self.discrepancy_name: + # Initialize the distances to inf + samples[node] = np.ones(shape, dtype=dtype) * np.inf + else: + samples[node] = np.empty(shape, dtype=dtype) + self.state['samples'] = samples def _merge_batch(self, batch): diff --git a/elfi/methods/results.py b/elfi/methods/results.py index 28f72706..ca4d9c52 100644 --- a/elfi/methods/results.py +++ b/elfi/methods/results.py @@ -144,6 +144,7 @@ def summary(self): print(desc, end='') self.posterior_means + # TODO: return the actual values, add axis=0 @property def posterior_means(self): """Print a representation of posterior means. diff --git a/elfi/model/tools.py b/elfi/model/tools.py index 1bfff88f..cc9f99ca 100644 --- a/elfi/model/tools.py +++ b/elfi/model/tools.py @@ -1,9 +1,10 @@ import subprocess +import warnings from functools import partial import numpy as np -from elfi.utils import get_sub_seed +from elfi.utils import get_sub_seed, is_array __all__ = ['vectorize', 'external_operation'] @@ -13,6 +14,7 @@ def run_vectorized(operation, *inputs, constants=None, batch_size=None, **kwargs """Runs the operation as if it was vectorized over the individual runs in the batch. Helper for cases when you have an operation that does not support vector arguments. + This tool is still experimental and may now work in all cases. Parameters ---------- @@ -42,22 +44,22 @@ def run_vectorized(operation, *inputs, constants=None, batch_size=None, **kwargs if i in constants: continue - try: + # Test if a numpy array + if is_array(inpt): l = len(inpt) - except: - constants.append(i) - l = 1 - - if l != 1: if batch_size is None: batch_size = l elif batch_size != l: raise ValueError('Batch size {} does not match with input {} length of ' - '{}. Please check `constants` for the vectorize ' - 'decorator.') + '{}. Please check `constants` argument for the ' + 'vectorize decorator.') + else: + constants.append(i) # If batch_size is still `None` set it to 1 as no inputs larger than it were found. if batch_size is None: + warnings.warn('Could not deduce the batch size from input data for the vectorized' + 'operation {}. Assuming batch size 1.'.format(operation)) batch_size = 1 # Run the operation batch_size times @@ -72,15 +74,12 @@ def run_vectorized(operation, *inputs, constants=None, batch_size=None, **kwargs inputs_i.append(inpt[index_in_batch]) # Replace the batch_size with index_in_batch - if uses_batch_size: - kwargs['index_in_batch'] = index_in_batch + if 'meta' in kwargs: + kwargs['meta']['index_in_batch'] = index_in_batch runs.append(operation(*inputs_i, **kwargs)) - if batch_size == 1: - return runs[0] - else: - return np.array(runs) + return np.array(runs) def vectorize(operation=None, constants=None): diff --git a/elfi/model/utils.py b/elfi/model/utils.py index 8d44c4a1..1e891c2b 100644 --- a/elfi/model/utils.py +++ b/elfi/model/utils.py @@ -30,7 +30,7 @@ def rvs_from_distribution(*params, batch_size, distribution, size=None, random_s size = (batch_size, ) + size rvs = distribution.rvs(*params, size=size, random_state=random_state) - return dict(output=rvs) + return rvs def distance_as_discrepancy(dist, *summaries, observed): diff --git a/elfi/utils.py b/elfi/utils.py index a7af450d..d3c86c1c 100644 --- a/elfi/utils.py +++ b/elfi/utils.py @@ -25,6 +25,11 @@ def args_to_tuple(*args): return tuple(args) +def is_array(output): + # Ducktyping numpy arrays + return hasattr(output, 'shape') + + # NetworkX utils diff --git a/tests/functional/test_consistency.py b/tests/functional/test_consistency.py deleted file mode 100644 index af4ca3dd..00000000 --- a/tests/functional/test_consistency.py +++ /dev/null @@ -1,4 +0,0 @@ -import pytest - -import numpy as np -import scipy.stats as ss diff --git a/tests/functional/test_custom_outputs.py b/tests/functional/test_custom_outputs.py new file mode 100644 index 00000000..d4d50c7e --- /dev/null +++ b/tests/functional/test_custom_outputs.py @@ -0,0 +1,39 @@ +import elfi + +import numpy as np + + +def simulator(p, random_state=None): + n = 30 + rs = random_state or np.random.RandomState() + data = rs.multinomial(n, p) + + # Make it a dict for testing purposes + return dict(zip(range(n), data)) + + +def summary(dict_data): + n = len(dict_data) + data = np.array([dict_data[i] for i in range(n)]) + return data/n + + +def test_dict_output(): + vsim = elfi.tools.vectorize(simulator) + vsum = elfi.tools.vectorize(summary) + + obs = simulator([.2, .8]) + + elfi.ElfiModel() + p = elfi.Prior('dirichlet', [2, 2]) + sim = elfi.Simulator(vsim, p, observed=obs) + S = elfi.Summary(vsum, sim) + d = elfi.Distance('euclidean', S) + + pool = elfi.OutputPool(['sim']) + rej = elfi.Rejection(d, batch_size=100, pool=pool, output_names=['sim']) + sample = rej.sample(100, n_sim=1000) + mean = np.mean(sample.samples['p'], axis=0) + + # Crude test + assert mean[0] < mean[1] From a469891dc5e202f22c5cb6d7fdc2222f152da073 Mon Sep 17 00:00:00 2001 From: Henri Vuollekoski Date: Tue, 20 Jun 2017 18:41:46 +0300 Subject: [PATCH 17/38] Fix case sigma=0 (#181) --- elfi/methods/bo/acquisition.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/elfi/methods/bo/acquisition.py b/elfi/methods/bo/acquisition.py index d5a127c1..81308d4a 100644 --- a/elfi/methods/bo/acquisition.py +++ b/elfi/methods/bo/acquisition.py @@ -106,10 +106,11 @@ def acquire(self, n_values, pending_locations=None, t=None): # add some noise for more efficient exploration if self._diagonal_cov: for ii in range(self.model.input_dim): - bounds_a = (self.model.bounds[ii][0] - x[:, ii]) / self._noise_sigma[ii] - bounds_b = (self.model.bounds[ii][1] - x[:, ii]) / self._noise_sigma[ii] - x[:, ii] = truncnorm.rvs(bounds_a, bounds_b, loc=x[:, ii], scale=self._noise_sigma[ii], - size=n_values, random_state=self.random_state) + if self._noise_sigma[ii] > 0: + bounds_a = (self.model.bounds[ii][0] - x[:, ii]) / self._noise_sigma[ii] + bounds_b = (self.model.bounds[ii][1] - x[:, ii]) / self._noise_sigma[ii] + x[:, ii] = truncnorm.rvs(bounds_a, bounds_b, loc=x[:, ii], scale=self._noise_sigma[ii], + size=n_values, random_state=self.random_state) else: x += multivariate_normal.rvs(cov=self.noise_cov, size=n_values, random_state=self.random_state) \ From d8c60ba5f6ce47e862383214a8378310b9fb02aa Mon Sep 17 00:00:00 2001 From: Henri Vuollekoski Date: Wed, 21 Jun 2017 11:25:06 +0300 Subject: [PATCH 18/38] Bounds must be given as dict for BO/BOLFI (#182) * Bounds must be given as dict for BO/BOLFI * Address comments to PR 182 --- elfi/methods/parameter_inference.py | 14 +++++++++++--- tests/functional/test_inference.py | 2 +- tests/unit/test_bo.py | 3 ++- tests/unit/test_methods.py | 2 +- 4 files changed, 15 insertions(+), 6 deletions(-) diff --git a/elfi/methods/parameter_inference.py b/elfi/methods/parameter_inference.py index 927d1a73..f451e3f2 100644 --- a/elfi/methods/parameter_inference.py +++ b/elfi/methods/parameter_inference.py @@ -745,12 +745,13 @@ def __init__(self, model, target_name=None, batch_size=1, initial_evidence=None, target_model : GPyRegression, optional acquisition_method : Acquisition, optional Method of acquiring evidence points. Defaults to LCBSC. - acq_noise_cov : float, or np.array of shape (n_params, n_params), optional + acq_noise_cov : float or np.array, optional Covariance of the noise added in the default LCBSC acquisition method. - bounds : list + If an array, should have the shape (n_params,) or (n_params, n_params). + bounds : dict The region where to estimate the posterior for each parameter in model.parameters. - `[(lower, upper), ... ]` + `{'parameter_name':(lower, upper), ... }` initial_evidence : int, dict, optional Number of initial evidence or a precomputed batch dict containing parameter and discrepancy values @@ -765,6 +766,13 @@ def __init__(self, model, target_name=None, batch_size=1, initial_evidence=None, super(BayesianOptimization, self).__init__(model, output_names, batch_size=batch_size, **kwargs) + if not isinstance(bounds, dict): + raise ValueError("Keyword `bounds` must be a dictionary " + "`{'parameter_name': (lower, upper), ... }`") + + # turn bounds dict into a list in the same order as parameter_names + bounds = [bounds[n] for n in model.parameter_names] + self.target_name = target_name target_model = \ target_model or GPyRegression(len(self.model.parameter_names), bounds=bounds) diff --git a/tests/functional/test_inference.py b/tests/functional/test_inference.py index 9bfd1c84..a14887e9 100644 --- a/tests/functional/test_inference.py +++ b/tests/functional/test_inference.py @@ -105,7 +105,7 @@ def test_BOLFI(): log_d = NodeReference(m['d'], state=dict(_operation=np.log), model=m, name='log_d') bolfi = elfi.BOLFI(log_d, initial_evidence=20, update_interval=10, batch_size=5, - bounds=[(-2,2), (-1, 1)], acq_noise_cov=.1) + bounds={'t1':(-2,2), 't2':(-1, 1)}, acq_noise_cov=.1) n = 300 res = bolfi.infer(300) assert bolfi.target_model.n_evidence == 300 diff --git a/tests/unit/test_bo.py b/tests/unit/test_bo.py index 154cab24..79339b4f 100644 --- a/tests/unit/test_bo.py +++ b/tests/unit/test_bo.py @@ -13,9 +13,10 @@ def test_BO(ma2): n_init = 20 res_init = elfi.Rejection(log_d, batch_size=5).sample(n_init, quantile=1) + bounds = {n:(-2, 2) for n in ma2.parameter_names} bo = elfi.BayesianOptimization(log_d, initial_evidence=res_init.outputs, update_interval=10, batch_size=5, - bounds=[(-2,2)]*len(ma2.parameter_names)) + bounds=bounds) assert bo.target_model.n_evidence == n_init assert bo.n_evidence == n_init assert bo._n_precomputed == n_init diff --git a/tests/unit/test_methods.py b/tests/unit/test_methods.py index 2eae5f59..0a3eec68 100644 --- a/tests/unit/test_methods.py +++ b/tests/unit/test_methods.py @@ -33,7 +33,7 @@ def test_BOLFI_short(ma2, distribution_test): log_d = elfi.Operation(np.log, ma2['d']) bolfi = elfi.BOLFI(log_d, initial_evidence=10, update_interval=10, batch_size=5, - bounds=[(-2,2), (-1, 1)]) + bounds={'t1':(-2,2), 't2':(-1, 1)}) n = 20 res = bolfi.infer(n) assert bolfi.target_model.n_evidence == n From b09b7eb1b253ccb08e0c5b9cecc6703c86d3c16a Mon Sep 17 00:00:00 2001 From: Jarno Lintusaari Date: Wed, 21 Jun 2017 15:39:12 +0300 Subject: [PATCH 19/38] Remove warning from vectorize tool. Make it a bit more robust. (#183) * Remove warning from vectorize tool. Make it a bit more robust. * Fix typo --- elfi/loader.py | 8 +- elfi/model/tools.py | 116 ++++++++++-------------- tests/functional/test_custom_outputs.py | 50 +++++++++- tests/unit/test_tools.py | 26 +++--- 4 files changed, 114 insertions(+), 86 deletions(-) diff --git a/elfi/loader.py b/elfi/loader.py index 990b57a4..b2f73e12 100644 --- a/elfi/loader.py +++ b/elfi/loader.py @@ -1,6 +1,6 @@ import numpy as np -from elfi.utils import observed_name, get_sub_seed +from elfi.utils import observed_name, get_sub_seed, is_array class Loader: @@ -27,16 +27,16 @@ def load(cls, context, compiled_net, batch_index): class ObservedLoader(Loader): """ - Add observed data to computation graph + Add the observed data to the compiled net """ @classmethod def load(cls, context, compiled_net, batch_index): - for name, v in context.observed.items(): + for name, obs in context.observed.items(): obs_name = observed_name(name) if not compiled_net.has_node(obs_name): continue - compiled_net.node[obs_name] = dict(output=v) + compiled_net.node[obs_name] = dict(output=obs) return compiled_net diff --git a/elfi/model/tools.py b/elfi/model/tools.py index cc9f99ca..98ab2d9f 100644 --- a/elfi/model/tools.py +++ b/elfi/model/tools.py @@ -10,11 +10,12 @@ __all__ = ['vectorize', 'external_operation'] -def run_vectorized(operation, *inputs, constants=None, batch_size=None, **kwargs): +def run_vectorized(operation, *inputs, constants=None, dtype=None, batch_size=None, + **kwargs): """Runs the operation as if it was vectorized over the individual runs in the batch. Helper for cases when you have an operation that does not support vector arguments. - This tool is still experimental and may now work in all cases. + This tool is still experimental and may not work in all cases. Parameters ---------- @@ -22,10 +23,10 @@ def run_vectorized(operation, *inputs, constants=None, batch_size=None, **kwargs Operation that will be run `batch_size` times. inputs Inputs from the parent nodes. - constants : tuple or int, optional - A mask for constants in inputs, e.g. (0, 2) would indicate that the first and - third input are constants. The constants will be passed as they are to each - operation call. + constants + See documentation from vectorize. + dtype + See documentation from vectorize. batch_size : int, optional kwargs @@ -35,8 +36,6 @@ def run_vectorized(operation, *inputs, constants=None, batch_size=None, **kwargs If batch_size > 1, a numpy array of outputs is returned """ - uses_batch_size = False if batch_size is None else True - constants = [] if constants is None else list(constants) # Check input and set constants and batch_size if needed @@ -52,18 +51,22 @@ def run_vectorized(operation, *inputs, constants=None, batch_size=None, **kwargs elif batch_size != l: raise ValueError('Batch size {} does not match with input {} length of ' '{}. Please check `constants` argument for the ' - 'vectorize decorator.') + 'vectorize decorator for marking constant inputs.') else: constants.append(i) # If batch_size is still `None` set it to 1 as no inputs larger than it were found. + # This occurs often with e.g. summary operations translating observed data if batch_size is None: - warnings.warn('Could not deduce the batch size from input data for the vectorized' - 'operation {}. Assuming batch size 1.'.format(operation)) batch_size = 1 + # Prepare the array for the results + if dtype is False: + runs = np.empty(batch_size, dtype=object) + else: + runs = [] + # Run the operation batch_size times - runs = [] for index_in_batch in range(batch_size): # Prepare inputs for this run inputs_i = [] @@ -77,84 +80,63 @@ def run_vectorized(operation, *inputs, constants=None, batch_size=None, **kwargs if 'meta' in kwargs: kwargs['meta']['index_in_batch'] = index_in_batch - runs.append(operation(*inputs_i, **kwargs)) + output = operation(*inputs_i, **kwargs) - return np.array(runs) + if dtype is False: + # Prevent anu potential casting of output + runs[index_in_batch] = output + else: + runs.append(output) + if dtype is not False: + runs = np.array(runs, dtype=dtype) -def vectorize(operation=None, constants=None): + return runs + + +def vectorize(operation, constants=None, dtype=None): """Vectorizes an operation. Helper for cases when you have an operation that does not support vector arguments. + This tool is still experimental and may not work in all cases. Parameters ---------- - operation : callable, optional - Operation to vectorize. Only pass this argument if you call this function - directly. - constants : tuple, optional - indexes of constants positional inputs for the operation. You can pass this as an - argument for the decorator. + operation : callable + Operation to vectorize. + constants : tuple, list, optional + A mask for constants in inputs, e.g. (0, 2) would indicate that the first and + third positional inputs are constants. The constants will be passed as they are to + each operation call. + dtype : np.dtype, bool[False], optional + If None, numpy converts a list of outputs automatically. In some cases this + produces non desired results. If you wish to keep the outputs as they are with + no conversion, specify dtype=False. This results into a 1d object numpy array + with outputs as they were returned. Notes ----- - The decorator form does not always produce a pickleable object. The parallel execution - requires the simulator to be pickleable. Therefore it is not recommended to use - the decorator syntax unless you are using `dill` or a similar package. - - This is a convenience method and uses a for loop for vectorization. For best - performance, one should aim to implement vectorized operations (by using e.g. numpy - functions that are mostly vectorized) if at all possible. - - If the output from the operation is not a numpy array or if the shape of the output - in different runs differs, the `dtype` of the returned numpy array will be `object`. - - If the node has a parameter `batch_index`, then also `run_index` will be added - to the passed parameters that tells the current index of this run within the batch, - i.e. 0 <= `run_index` < `batch_size`. + This is a convenience method that uses a for loop internally for the + vectorization. For best performance, one should aim to implement vectorized operations + (by using e.g. numpy functions that are mostly vectorized) if at all possible. Examples -------- :: - # Call directly (recommended) + # This form works in most cases vectorized_simulator = elfi.tools.vectorize(simulator) - # As a decorator without arguments - @elfi.tools.vectorize - def simulator(a, b, random_state=None): - # Simulator code - pass - - @elfi.tools.vectorize(constants=1) - def simulator(a, constant, random_state=None): - # Simulator code - pass - - @elfi.tools.vectorize(1) - def simulator(a, constant, random_state=None): - # Simulator code - pass + # Tell that the second and third argument to the simulator will be a constant + vectorized_simulator = elfi.tools.vectorize(simulator, [1, 2]) + elfi.Simulator(vectorized_simulator, prior, constant_1, constant_2) - @elfi.tools.vectorize(constants=(0,2)) - def simulator(constant0, b, constant2, random_state=None): - # Simulator code - pass + # Tell the vectorizer that it should not do any conversion to the outputs + vectorized_simulator = elfi.tools.vectorize(simulator, dtype=False) """ # Cases direct call or a decorator without arguments - if callable(operation): - return partial(run_vectorized, operation, constants=constants) - - # Decorator with parameters - elif isinstance(operation, int): - constants = tuple([operation]) - elif isinstance(operation, (tuple, list)): - constants = tuple(operation) - elif isinstance(constants, int): - constants = tuple([constants]) - - return partial(partial, run_vectorized, constants=constants) + return partial(run_vectorized, operation, constants=constants, dtype=dtype) def unpack_meta(*inputs, **kwinputs): diff --git a/tests/functional/test_custom_outputs.py b/tests/functional/test_custom_outputs.py index d4d50c7e..75549850 100644 --- a/tests/functional/test_custom_outputs.py +++ b/tests/functional/test_custom_outputs.py @@ -1,4 +1,6 @@ +import pytest import elfi +from elfi.utils import is_array import numpy as np @@ -18,6 +20,21 @@ def summary(dict_data): return data/n +def lsimulator(p, random_state=None): + n = 30 + rs = random_state or np.random.RandomState() + data = rs.multinomial(n, p) + + # Make it a list for testing purposes + return list(data) + ['test'] + + +def lsummary(list_data): + n = len(list_data) + data = np.array(list_data[:-1]) + return data/(n-1) + + def test_dict_output(): vsim = elfi.tools.vectorize(simulator) vsum = elfi.tools.vectorize(summary) @@ -36,4 +53,35 @@ def test_dict_output(): mean = np.mean(sample.samples['p'], axis=0) # Crude test - assert mean[0] < mean[1] + assert mean[1] - mean[0] > .2 + + +def test_list_output(): + vsim = elfi.tools.vectorize(lsimulator) + vsum = elfi.tools.vectorize(lsummary) + + v = vsim(np.array([[.2, .8], [.3, .7]])) + assert is_array(v) + assert not isinstance(v[0], list) + + vsim = elfi.tools.vectorize(lsimulator, dtype=False) + + v = vsim(np.array([[.2, .8], [.3, .7]])) + assert is_array(v) + assert isinstance(v[0], list) + + obs = lsimulator([.2, .8]) + + elfi.ElfiModel() + p = elfi.Prior('dirichlet', [2, 2]) + sim = elfi.Simulator(vsim, p, observed=obs) + S = elfi.Summary(vsum, sim) + d = elfi.Distance('euclidean', S) + + pool = elfi.OutputPool(['sim']) + rej = elfi.Rejection(d, batch_size=100, pool=pool, output_names=['sim']) + sample = rej.sample(100, n_sim=1000) + mean = np.mean(sample.samples['p'], axis=0) + + # Crude test + assert mean[1] - mean[0] > .2 diff --git a/tests/unit/test_tools.py b/tests/unit/test_tools.py index e7626d95..5e144787 100644 --- a/tests/unit/test_tools.py +++ b/tests/unit/test_tools.py @@ -11,33 +11,31 @@ def test_vectorize_decorator(): a = np.array([1,2,3]) b = np.array([3,2,1]) - @elfi.tools.vectorize - def simulator(a, b, random_state=None, index_in_batch=None): + def simulator(a, b, random_state=None): return a*b - assert np.array_equal(a * b, simulator(a, b, batch_size=batch_size)) + vsim = elfi.tools.vectorize(simulator) + assert np.array_equal(a * b, vsim(a, b, batch_size=batch_size)) - @elfi.tools.vectorize(constants=1) - def simulator(a, constant, random_state=None, index_in_batch=None): + def simulator(a, constant, random_state=None): return a*constant - assert np.array_equal(a * 5, simulator(a, 5, batch_size=batch_size)) + vsim = elfi.tools.vectorize(simulator, constants=[1]) + assert np.array_equal(a * 5, vsim(a, 5, batch_size=batch_size)) - @elfi.tools.vectorize(1) - def simulator(a, constant, random_state=None, index_in_batch=None): - return a*constant + vsim = elfi.tools.vectorize(simulator, [1]) + assert np.array_equal(a * 5, vsim(a, 5, batch_size=batch_size)) - assert np.array_equal(a * 5, simulator(a, 5, batch_size=batch_size)) - @elfi.tools.vectorize(constants=(0, 2)) - def simulator(constant0, b, constant2, random_state=None, index_in_batch=None): + def simulator(constant0, b, constant2, random_state=None): return constant0*b*constant2 - assert np.array_equal(2 * b * 7, simulator(2, b, 7, batch_size=batch_size)) + vsim = elfi.tools.vectorize(simulator, constants=(0, 2)) + assert np.array_equal(2 * b * 7, vsim(2, b, 7, batch_size=batch_size)) # Invalid batch size in b with pytest.raises(ValueError): - simulator(2, b, 7, batch_size=2*batch_size) + vsim(2, b, 7, batch_size=2*batch_size) def simulator(): From 62ea9b11468e0c14aceeba4d93dfad5f1afbf994 Mon Sep 17 00:00:00 2001 From: Jarno Lintusaari Date: Thu, 22 Jun 2017 12:01:12 +0300 Subject: [PATCH 20/38] Loosen a too tight time limit (#187) --- tests/functional/test_simulation_reuse.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/tests/functional/test_simulation_reuse.py b/tests/functional/test_simulation_reuse.py index 272864d7..b413eea1 100644 --- a/tests/functional/test_simulation_reuse.py +++ b/tests/functional/test_simulation_reuse.py @@ -1,9 +1,6 @@ import pytest import time -import numpy as np -import scipy.stats as ss - import elfi @@ -19,7 +16,7 @@ def test_pool(sleep_model): td = time.time() - ts # Will make 5/.25 = 20 evaluations with mean time of .1 secs, so 2 secs total on # average. Allow some slack although still on rare occasions this may fail. - assert td > 1.3 + assert td > 1.2 # Instantiating new inference with the same pool should be faster because we # use the prepopulated pool @@ -27,7 +24,7 @@ def test_pool(sleep_model): ts = time.time() res = rej.sample(5, quantile=quantile) td = time.time() - ts - assert td < 1.3 + assert td < 1.2 # It should work if we remove the simulation, since the Rejection sampling # only requires the parameters and the discrepancy @@ -36,7 +33,7 @@ def test_pool(sleep_model): ts = time.time() res = rej.sample(5, quantile=quantile) td = time.time() - ts - assert td < 1.3 + assert td < 1.2 # It should work even if we remove the discrepancy, since the discrepancy can be recomputed # from the stored summary @@ -45,7 +42,7 @@ def test_pool(sleep_model): ts = time.time() res = rej.sample(5, quantile=quantile) td = time.time() - ts - assert td < 1.3 + assert td < 1.2 From 217750763b33f344df4654eb592dac4a39117e01 Mon Sep 17 00:00:00 2001 From: Jarno Lintusaari Date: Thu, 22 Jun 2017 12:05:54 +0300 Subject: [PATCH 21/38] Added some new documentation (#184) * Good to know and architecture docs * Rebuild the docs with the new docs * Review fixes and additional paragraph on data reuse --- docs/developer/architecture.rst | 97 ++++++++++++++++++++++++++++++++- docs/developer/extensions.rst | 2 - docs/good-to-know.rst | 53 ++++++++++++++++++ docs/index.rst | 2 + elfi/model/elfi_model.py | 97 ++------------------------------- requirements-dev.txt | 2 - 6 files changed, 156 insertions(+), 97 deletions(-) delete mode 100644 docs/developer/extensions.rst create mode 100644 docs/good-to-know.rst diff --git a/docs/developer/architecture.rst b/docs/developer/architecture.rst index b6a3499a..c3dcd717 100644 --- a/docs/developer/architecture.rst +++ b/docs/developer/architecture.rst @@ -1,2 +1,97 @@ ELFI architecture -================= \ No newline at end of file +================= + +Here we explain the internal representation of the generative model in ELFI. This +representation contains everything that is needed to generate data, but is separate from +e.g. the inference methods or the data storages. This information is aimed for developers +and is not essential for using ELFI. We assume the reader is quite familiar with Python +and has perhaps already read some of ELFI's source code. + +The low level representation of the generative model is a `networkx.DiGraph` with nodes +represented as Python dictionaries that are called node state dictionaries. This +representation is held in `ElfiModel.source_net`. Before the generative model can be ran, +it needs to be compiled and loaded with data (e.g. observed data, precomputed data, batch +index, batch size etc). The compilation and loading of data is the responsibility of the +`Client` implementation and makes it possible in essence to translate `ElfiModel` to any +kind of computational backend. Finally the class `Executor` is responsible for +running the compiled and loaded model and producing the outputs of the nodes. + +A user typically creates this low level representation by working with subclasses of +`NodeReference`. These are easy to use UI classes of ELFI such as the `elfi.Simulator` or +`elfi.Prior`. Under the hood they create proper node state dictionaries stored into the +`source_net`. The callables such as simulators or summaries that the user provides to +these classes are called operations. + + +The model graph representation +------------------------------ + +The `source_net` is a directed acyclic graph (DAG) and holds the state dictionaries of the +nodes and the edges between the nodes. An edge represents a dependency. For example and +edge from a prior node to the simulator node represents that the simulator requires a +value from the prior to be able to run. The edge name corresponds to a parameter name for +the operation, with integer names interpreted as positional parameters. + +In the standard compilation process, the `source_net` is augmented with additional nodes +such as batch_size or random_state, that are then added as dependencies for those +operations that require them. In addition the state dicts will be turned into either a +runnable operation or a precomputed value. + +The execution order of the nodes in the compiled graph follows the topological ordering of +the DAG (dependency order) and is guaranteed to be the same every time. Note that because +the default behaviour is that nodes share a random state, changing a node that uses a +shared random state will affect the result of any later node in the ordering using the +same random state, even if they would be independent based on the graph topology. + + +State dictionary +---------------- + +The state of a node is a Python dictionary. It describes the type of the node and any +other relevant state information, such as the user provided callable operation (e.g. +simulator or summary statistic) and any additional parameters the operation needs to be +provided in the compilation. + +The following are reserved keywords of the state dict that serve as instructions for the +ELFI compiler. They begin with an underscore. Currently these are: + +_operation : callable + Operation of the node producing the output. Can not be used if _output is present. +_output : variable + Constant output of the node. Can not be used if _operation is present. +_class : class + The subclass of `NodeReference` that created the state. +_stochastic : bool, optional + Indicates that the node is stochastic. ELFI will provide a random_state argument + for such nodes, which contains a RandomState object for drawing random quantities. + This node will appear in the computation graph. Using ELFI provided random states + makes it possible to have repeatable experiments in ELFI. +_observable : bool, optional + Indicates that there is observed data for this node or that it can be derived from the + observed data. ELFI will create a corresponding observed node into the compiled graph. + These nodes are dependencies of discrepancy nodes. +_uses_batch_size : bool, optional + Indicates that the node operation requires `batch_size` as input. A corresponding edge + from batch_size node to this node will be added to the compiled graph. +_uses_meta : bool, optional + Indicates that the node operation requires meta information dictionary about the + execution. This includes, model name, batch index and submission index. + Useful for e.g. creating informative and unique file names. If the operation is + vectorized with `elfi.tools.vectorize`, then also `index_in_batch` will be added to + the meta information dictionary. +_uses_observed : bool, optional + Indicates that the node requires the observed data of its parents in the source_net as + input. ELFI will gather the observed values of its parents to a tuple and link them to + the node as a named argument observed. +_parameter : bool, optional + Indicates that the node is a parameter node + + +The compilation and data loading phases +--------------------------------------- + +The compilation of the computation graph is separated from the loading of the data for +making it possible to reuse the compiled model. The subclasses of the `Loader` class +take responsibility of injecting data to the nodes of the compiled model. Examples of +injected data are precomputed values from the `OutputPool`, the current `random_state` and +so forth. diff --git a/docs/developer/extensions.rst b/docs/developer/extensions.rst deleted file mode 100644 index 75df4a4c..00000000 --- a/docs/developer/extensions.rst +++ /dev/null @@ -1,2 +0,0 @@ -Extending ELFI -============== \ No newline at end of file diff --git a/docs/good-to-know.rst b/docs/good-to-know.rst new file mode 100644 index 00000000..0d6c04d1 --- /dev/null +++ b/docs/good-to-know.rst @@ -0,0 +1,53 @@ +Good to know +============ + +Here we describe some important concepts related to ELFI. These will help in understanding +how to implement custom operations (such as simulators or summaries) and can potentially +save the user from some pitfalls. + +Generative model +---------------- + +By a generative model we mean any model that can generate some data. In ELFI the +generative model is described with a `directed acyclic graph (DAG)`_ and the representation +is stored in the `ElfiModel`_ instance. It typically includes everything from the prior +distributions up to the summaries or distances. + +.. _`directed acyclic graph (DAG)`: https://en.wikipedia.org/wiki/Directed_acyclic_graph + +.. _`ElfiModel`: api.html#elfi.ElfiModel + + +Operations +---------- + +Operations are functions (or more generally Python callables) in the nodes of the +generative model. Those nodes that deal directly with data, e.g. priors, simulators, +summaries and distances should return a numpy array of length ``batch_size`` that contains +their output. + +If your operation does not produce data wrapped to numpy arrays, you can use the +`elfi.tools.vectorize`_ tool to achieve that. Note that sometimes it is required to specify +which arguments to the vectorized function will be constants and at other times also +specify the datatype (when automatic numpy array conversion does not produce desired +result). It is always good to check that the output is sane using the ``node.generate`` +method. + +.. _`elfi.tools.vectorize`: api.html#elfi.tools.vectorize + +Reusing data +------------ + +The `OutputPool`_ object can be used to store the outputs of any node in the graph. Note +however that changing a node in the model will change the outputs of it's child nodes. In +Rejection sampling you can alter any nodes that are children of the nodes in the +`OutputPool`_ and safely reuse the `OutputPool`_ with the modified model. This is +especially handy when saving the simulations and trying out different summaries. + +However the other algorithms will produce biased results if you reuse the `OutputPool`_ +with a modified model. This is because they learn from the previous results and decide +the new parameter values based on them. The Rejection sampling does not suffer from this +because it always samples new parameter values directly from the priors, and therefore +modified distance outputs have no effect to the parameter values of any later simulations. + +.. _`OutputPool`: api.html#elfi.OutputPool \ No newline at end of file diff --git a/docs/index.rst b/docs/index.rst index ea23c074..6cd9469e 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -40,6 +40,7 @@ ELFI also has the following non LFI methods: :caption: Getting started installation + good-to-know quickstart api @@ -56,6 +57,7 @@ ELFI also has the following non LFI methods: :maxdepth: 1 :caption: Developer documentation + developer/architecture developer/contributing .. faq diff --git a/elfi/model/elfi_model.py b/elfi/model/elfi_model.py index 33f65826..b768f12e 100644 --- a/elfi/model/elfi_model.py +++ b/elfi/model/elfi_model.py @@ -14,101 +14,14 @@ from elfi.utils import scipy_from_str, observed_name __all__ = ['ElfiModel', 'ComputationContext', 'NodeReference', - 'Constant', 'Operation', + 'Constant', 'Operation', 'RandomVariable', 'Prior', 'Simulator', 'Summary', 'Discrepancy', 'Distance', 'get_current_model', 'set_current_model'] -""" This module contains the classes for creating generative models in ELFI. The class that -contains the whole representation of this generative model is named `ElfiModel`. - -The low level representation of the generative model is a `networkx.DiGraph` with nodes -represented as Python dictionaries that are called node state dictionaries. This -representation is held in `ElfiModel.source_net`. Before the generative model can be ran, -it needs to be compiled and loaded with data (e.g. observed data, precomputed data, batch -index, batch size etc). The compilation and loading of data is the responsibility of the -`Client` implementation and makes it possible in essence to translate ElfiModel to any -kind of computational backend. Finally the class `elfi.Executor` is responsible for -running the compiled and loaded model and producing the outputs of the nodes. - -A user typically creates this low level representation by working with subclasses of -`NodeReference`. These are easy to use UI classes of ELFI. Under the hood they create -proper node state dictionaries stored into the `source_net`. The callables such as -simulators or summaries that the user provides to these classes are called operations. - - -The model graph representation ------------------------------- - -The `source_net` is a directed acyclic graph (DAG) and holds the state dictionaries of the nodes -and the edges between the nodes. An edge represents a dependency. For example and edge -from a prior node to the simulator node represents that the simulator requires a value -from the prior to be able to run. The edge name corresponds to a parameter name for the -operation, with integer names interpreted as positional parameters. - -In the standard compilation process, the `source_net` is augmented with additional nodes -such as batch_size or random_state, that are then added as dependencies for those -operations that require them. In addition the state dicts will be turned into either a -runnable operation or a precomputed value. - -The execution order of the nodes in the compiled graph follows the topological ordering of -the DAG (dependency order) and is guaranteed to be the same every time. Note that because -the default behaviour is that nodes share a random state, changing a node that uses shared -random state will affect the result of any later node in the ordering using the same -shared random state even if they would not be depended based on the graph topology. If -this is an issue, separate random states can be created. - - -State dictionary ----------------- - -The state of a node is a Python dictionary. It describes the type of the node and any -other relevant state information, such as the user provided callable operation (e.g. -simulator or summary statistic) and any additional parameters the operation needs to be -provided in the compilation. - -The following are reserved keywords of the state dict that serve as instructions for the -ELFI compiler. They begin with an underscore. Currently these are: - -_operation : callable - Operation of the node producing the output. Can not be used if _output is present. -_output : variable - Constant output of the node. Can not be used if _operation is present. -_class : class - The subclass of `NodeReference` that created the state. -_stochastic : bool, optional - Indicates that the node is stochastic. ELFI will provide a random_state argument - for such nodes, which contains a RandomState object for drawing random quantities. - This node will appear in the computation graph. Using ELFI provided random states - makes it possible to have repeatable experiments in ELFI. -_observable : bool, optional - Indicates that there is observed data for this node or that it can be derived from the - observed data. ELFI will create a corresponding observed node into the compiled graph. - These nodes are dependencies of discrepancy nodes. -_uses_batch_size : bool, optional - Indicates that the node operation requires `batch_size` as input. A corresponding edge - from batch_size node to this node will be added to the compiled graph. -_uses_meta : bool, optional - Indicates that the node operation requires meta information dictionary about the - execution. This includes, model name, batch index and submission index. - Useful for e.g. creating informative and unique file names. If the operation is - vectorized with `elfi.tools.vectorize`, then also `index_in_batch` will be added to - the meta information dictionary. -_uses_observed : bool, optional - Indicates that the node requires the observed data of its parents in the source_net as - input. ELFI will gather the observed values of its parents to a tuple and link them to - the node as a named argument observed. -_parameter : bool, optional - Indicates that the node is a parameter node - - -The compilation and data loading phases ---------------------------------------- - -The compilation of the computation graph is separated from the loading of the data for -making it possible to reuse the compiled model. The loader objects are passed the -context, compiled net, - -""" + +"""This module contains the classes for creating generative models in ELFI. The class that +describes the generative model is named `ElfiModel`.""" + _current_model = None diff --git a/requirements-dev.txt b/requirements-dev.txt index 5a6d9a07..a14228a0 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -13,5 +13,3 @@ flake8-isort>=2.0.1 # Documentation Sphinx>=1.4.8 -numpydoc>=0.6 - From 9b7c50b46d1b4a142739b47d60fd0e3c64557c9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kusti=20Skyt=C3=A9n?= Date: Thu, 22 Jun 2017 15:01:40 +0300 Subject: [PATCH 22/38] Add linear regression adjustment (#175) * Initial draft of linear regression adjustment * Add documentation * Add scikit-learn as a requirement and import post-processing * Improve the interface * Add convenience function * Fix adjusting multiple parameters * Return results as objects * Add regression tests * Add documentation * Fix doctest * Improve API and add documentation * Fix regression tests * Replace BDM with a Gaussian simulator * Fix doctest * Handle non-finite elements * Use the new API * Document non-finite behaviour * Make adjust_posterior more convenient * Add test for nonfinite values * Make parameter names optional * Fix doctest * Fix docstrings * Add API documentation * Fix paths in API docs * Use backticks --- docs/api.rst | 16 ++ elfi/__init__.py | 3 +- elfi/methods/post_processing.py | 266 +++++++++++++++++++++++ requirements.txt | 1 + tests/functional/test_post_processing.py | 125 +++++++++++ 5 files changed, 410 insertions(+), 1 deletion(-) create mode 100644 elfi/methods/post_processing.py create mode 100644 tests/functional/test_post_processing.py diff --git a/docs/api.rst b/docs/api.rst index 39306e1e..162d9e22 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -59,6 +59,12 @@ Below is a list of inference methods included in ELFI. SmcSample BolfiSample +**Post-processing** + +.. autosummary:: + elfi.adjust_posterior + elfi.methods.post_processing.LinearAdjustment + Other ----- @@ -182,6 +188,16 @@ Inference API classes :members: :inherited-members: +.. currentmodule:: elfi.methods.post_processing + +.. autoclass:: RegressionAdjustment + :members: + :inherited-members: + +.. autoclass:: LinearAdjustment + :members: + :inherited-members: + Other ..... diff --git a/elfi/__init__.py b/elfi/__init__.py index d5f72dcf..0f7a4722 100644 --- a/elfi/__init__.py +++ b/elfi/__init__.py @@ -6,6 +6,7 @@ import elfi.model.tools as tools from elfi.client import get_client, set_client from elfi.methods.parameter_inference import * +from elfi.methods.post_processing import adjust_posterior from elfi.model.elfi_model import * from elfi.model.extensions import ScipyLikeDistribution as Distribution from elfi.store import OutputPool, ArrayPool @@ -15,4 +16,4 @@ __email__ = 'elfi-support@hiit.fi' # make sure __version_ is on the last non-empty line (read by setup.py) -__version__ = '0.5.0' \ No newline at end of file +__version__ = '0.5.0' diff --git a/elfi/methods/post_processing.py b/elfi/methods/post_processing.py new file mode 100644 index 00000000..283eebe1 --- /dev/null +++ b/elfi/methods/post_processing.py @@ -0,0 +1,266 @@ +""" +Post-processing for posterior samples from other ABC algorithms. + +References +---------- +Fundamentals and Recent Developments in Approximate Bayesian Computation +Lintusaari et. al +Syst Biol (2017) 66 (1): e66-e82. +https://doi.org/10.1093/sysbio/syw077 +""" +import warnings + +from sklearn.linear_model import LinearRegression +import numpy as np + +from . import results + + +__all__ = ('LinearAdjustment', 'adjust_posterior') + + +class RegressionAdjustment(object): + """Base class for regression adjustments. + + Each parameter is assumed to be a scalar. A local regression is + fitted for each parameter individually using the values of the + summary statistics as the regressors. The regression model can be + any object implementing a `fit()` method. All keyword arguments + given to the constructor are passed to the regression model. + + Subclasses need to implement the methods `_adjust` and + `_input_variables`. They must also specify the class variables + `_regression_model` and `_name`. See the individual documentation + and the `LinearAdjustment` class for further detail. + + Parameters + ---------- + kwargs** + keyword arguments to pass to the regression model + + Attributes + ---------- + regression_models + a list of fitted regression model instances + parameter_names + a list of parameter names + sample + the sample object from an ABC algorithm + X + the regressors for the regression model + """ + _regression_model = None + _name = 'RegressionAdjustment' + + def __init__(self, **kwargs): + self._model_kwargs = kwargs + self._fitted = False + self.regression_models = [] + self._X = None + self._sample = None + self._parameter_names = None + self._finite = [] + + @property + def parameter_names(self): + self._check_fitted() + return self._parameter_names + + @property + def sample(self): + self._check_fitted() + return self._sample + + @property + def X(self): + self._check_fitted() + return self._X + + def _check_fitted(self): + if not self._fitted: + raise ValueError("The regression model must be fitted first. " + "Use the fit() method.") + + def fit(self, sample, model, summary_names, parameter_names=None): + """Fit a regression adjustment model to the posterior sample. + + Non-finite values in the summary statistics and parameters + will be omitted. + + Parameters + ---------- + sample : elfi.methods.Sample + a sample object from an ABC method + model : elfi.ElfiModel + the inference model + summary_names : list[str] + a list of names for the summary nodes + parameter_names : list[str] (optional) + a list of parameter names + """ + self._X = self._input_variables(model, sample, summary_names) + self._sample = sample + self._parameter_names = parameter_names or sample.parameter_names + self._get_finite() + + for pair in self._pairs(): + self.regression_models.append(self._fit1(*pair)) + + self._fitted = True + + def _fit1(self, X, y): + return self._regression_model(**self._model_kwargs).fit(X, y) + + def _pairs(self): + # TODO: Access the variables through the getters + for (i, name) in enumerate(self._parameter_names): + X = self._X[self._finite[i], :] + p = self._sample.outputs[name][self._finite[i]] + yield X, p + + def adjust(self): + """Adjust the posterior. + + Only the non-finite values used to fit the regression model + will be adjusted. + + Returns + ------- + a Sample object containing the adjusted posterior + """ + outputs = {} + for (i, name) in enumerate(self.parameter_names): + theta_i = self.sample.outputs[name][self._finite[i]] + adjusted = self._adjust(i, theta_i, self.regression_models[i]) + outputs[name] = adjusted + + res = results.Sample(method_name=self._name, outputs=outputs, + parameter_names=self._parameter_names) + return res + + def _adjust(self, i, theta_i, regression_model): + """Adjust a single parameter using a fitted regression model. + + Parameters + ---------- + i : int + the index of the parameter + theta_i : np.ndarray + a vector of parameter values to adjust + regression_model + a fitted regression model + + Returns + ------- + adjusted_theta_i : np.ndarray + an adjusted version of the parameter values + """ + raise NotImplementedError + + def _input_variables(self, model, sample, summary_names): + """Construct a matrix of regressors. + + Parameters + ---------- + model : elfi.ElfiModel + the inference model + sample + a sample object from an ABC algorithm + summary_names : list[str] + names of the summary nodes + + Returns + ------- + X + a numpy array of regressors + """ + raise NotImplementedError + + def _get_finite(self): + # TODO: Access the variables through the getters + finite_inputs = np.isfinite(self._X).all(axis=1) + finite = [finite_inputs & np.isfinite(self._sample.outputs[p]) + for p in self._parameter_names] + all_finite = all(map(all, finite)) + self._finite = finite + if not (all(finite_inputs) and all_finite): + warnings.warn("Non-finite inputs and outputs will be omitted.") + + +class LinearAdjustment(RegressionAdjustment): + """Regression adjustment using a local linear model.""" + _regression_model = LinearRegression + _name = 'LinearAdjustment' + + def __init__(self, **kwargs): + super(LinearAdjustment, self).__init__(**kwargs) + + def _adjust(self, i, theta_i, regression_model): + b = regression_model.coef_ + return theta_i - self.X[self._finite[i], :].dot(b) + + def _input_variables(self, model, sample, summary_names): + """Regress on the differences to the observed summaries.""" + observed_summaries = np.stack([model[s].observed + for s in summary_names], axis=1) + summaries = np.stack([sample.outputs[name] + for name in summary_names], axis=1) + return summaries - observed_summaries + + +def adjust_posterior(sample, model, summary_names, + parameter_names=None, adjustment='linear'): + """Adjust the posterior using local regression. + + Note that the summary nodes need to be explicitly included to the + sample object with the `output_names` keyword argument when performing + the inference. + + Parameters + ---------- + sample : elfi.methods.results.Sample + a sample object from an ABC algorithm + model : elfi.ElfiModel + the inference model + summary_names : list[str] + names of the summary nodes + parameter_names : list[str] (optional) + names of the parameters + adjustment : RegressionAdjustment or string + a regression adjustment object or a string specification + + Accepted values for the string specification: + - 'linear' + + Returns + ------- + sample + a Sample object with the adjusted posterior + + Examples + -------- + + >>> import elfi + >>> from elfi.examples import gauss + >>> m = gauss.get_model() + >>> res = elfi.Rejection(m['d'], output_names=['S1', 'S2']).sample(1000) + >>> adj = adjust_posterior(res, m, ['S1', 'S2'], ['mu'], LinearAdjustment()) + """ + adjustment = _get_adjustment(adjustment) + adjustment.fit(model=model, sample=sample, + parameter_names=parameter_names, + summary_names=summary_names) + return adjustment.adjust() + + +def _get_adjustment(adjustment): + adjustments = {'linear': LinearAdjustment} + + if isinstance(adjustment, RegressionAdjustment): + return adjustment + elif isinstance(adjustment, str): + try: + return adjustments.get(adjustment, None)() + except TypeError: + raise ValueError("Could not find " + "adjustment method:{}".format(adjustment)) diff --git a/requirements.txt b/requirements.txt index 10e03501..e1fe765a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,3 +5,4 @@ GPy>=1.0.9 networkX>=1.11 ipyparallel>=6 toolz>=0.8 +scikit-learn>=0.18.1 diff --git a/tests/functional/test_post_processing.py b/tests/functional/test_post_processing.py new file mode 100644 index 00000000..91933da1 --- /dev/null +++ b/tests/functional/test_post_processing.py @@ -0,0 +1,125 @@ +from functools import partial + +import numpy as np +import pytest + +import elfi +from elfi.examples import gauss, ma2 +from elfi.methods.post_processing import LinearAdjustment, adjust_posterior +import elfi.methods.post_processing as pp + + +def _statistics(arr): + return arr.mean(), arr.var() + + +def test_get_adjustment(): + with pytest.raises(ValueError): + pp._get_adjustment('doesnotexist') + + +def test_single_parameter_linear_adjustment(): + """A regression test against values obtained in the notebook.""" + seed = 20170616 + n_obs = 50 + batch_size = 100 + mu, sigma = (5, 1) + + # Hyperparameters + mu0, sigma0 = (10, 100) + + y_obs = gauss.Gauss(mu, sigma, n_obs=n_obs, batch_size=1, + random_state=np.random.RandomState(seed)) + sim_fn = partial(gauss.Gauss, sigma=sigma, n_obs=n_obs) + + # Posterior + n = y_obs.shape[1] + mu1 = (mu0/sigma0**2 + y_obs.sum()/sigma**2)/(1/sigma0**2 + n/sigma**2) + sigma1 = (1/sigma0**2 + n/sigma**2)**(-0.5) + + # Model + m = elfi.ElfiModel(set_current=False) + elfi.Prior('norm', mu0, sigma0, model=m, name='mu') + elfi.Simulator(sim_fn, m['mu'], observed=y_obs, name='Gauss') + elfi.Summary(lambda x: x.mean(axis=1), m['Gauss'], name='S1') + elfi.Distance('euclidean', m['S1'], name='d') + + res = elfi.Rejection(m['d'], output_names=['S1'], + seed=seed).sample(1000, threshold=1) + adj = elfi.adjust_posterior(model=m, sample=res, + parameter_names=['mu'], + summary_names=['S1']) + + assert _statistics(adj.outputs['mu']) == (4.9772879640569778, 0.02058680115402544) + + +# TODO: Use a fixture for the model +def test_nonfinite_values(): + """A regression test against values obtained in the notebook.""" + seed = 20170616 + n_obs = 50 + batch_size = 100 + mu, sigma = (5, 1) + + # Hyperparameters + mu0, sigma0 = (10, 100) + + y_obs = gauss.Gauss(mu, sigma, n_obs=n_obs, batch_size=1, + random_state=np.random.RandomState(seed)) + sim_fn = partial(gauss.Gauss, sigma=sigma, n_obs=n_obs) + + # Posterior + n = y_obs.shape[1] + mu1 = (mu0/sigma0**2 + y_obs.sum()/sigma**2)/(1/sigma0**2 + n/sigma**2) + sigma1 = (1/sigma0**2 + n/sigma**2)**(-0.5) + + # Model + m = elfi.ElfiModel(set_current=False) + elfi.Prior('norm', mu0, sigma0, model=m, name='mu') + elfi.Simulator(sim_fn, m['mu'], observed=y_obs, name='Gauss') + elfi.Summary(lambda x: x.mean(axis=1), m['Gauss'], name='S1') + elfi.Distance('euclidean', m['S1'], name='d') + + res = elfi.Rejection(m['d'], output_names=['S1'], + seed=seed).sample(1000, threshold=1) + + # Add some invalid values + res.outputs['mu'] = np.append(res.outputs['mu'], np.array([np.inf])) + res.outputs['S1'] = np.append(res.outputs['S1'], np.array([np.inf])) + + with pytest.warns(UserWarning): + adj = elfi.adjust_posterior(model=m, sample=res, + parameter_names=['mu'], + summary_names=['S1']) + + assert _statistics(adj.outputs['mu']) == (4.9772879640569778, + 0.02058680115402544) + + +def test_multi_parameter_linear_adjustment(): + """A regression test against values obtained in the notebook.""" + seed = 20170511 + threshold = 0.2 + batch_size = 1000 + n_samples = 500 + m = ma2.get_model(true_params=[0.6, 0.2], seed_obs=seed) + + summary_names = ['S1', 'S2'] + parameter_names = ['t1', 't2'] + linear_adjustment = LinearAdjustment() + + res = elfi.Rejection(m['d'], batch_size=batch_size, + output_names=['S1', 'S2'], + # output_names=summary_names, # fails ?!?!? + seed=seed).sample(n_samples, threshold=threshold) + adjusted = adjust_posterior(model=m, sample=res, + parameter_names=parameter_names, + summary_names=summary_names, + adjustment=linear_adjustment) + t1 = adjusted.outputs['t1'] + t2 = adjusted.outputs['t2'] + + t1_mean, t1_var = (0.51606048286584782, 0.017253007645871756) + t2_mean, t2_var = (0.15805189695581101, 0.028004406914362647) + assert _statistics(t1) == (t1_mean, t1_var) + assert _statistics(t2) == (t2_mean, t2_var) From a08e7a37ccc1e80d1e7402daf8f6edd72a5f37d4 Mon Sep 17 00:00:00 2001 From: Henri Vuollekoski Date: Thu, 22 Jun 2017 15:12:33 +0300 Subject: [PATCH 23/38] Misc fixes (#185) * Disable warnings during plotting of posterior. Enable logpdf. * Allow list as acq_noise_cov * Don't mark points outside priors as diverged. * Draw the latest point in red only if interactive * GPyRegression now takes parameter_names and dict of bounds * Don't initialize in unallowed regions... * Address comments to PR 185 --- CHANGELOG.rst | 1 + elfi/methods/bo/acquisition.py | 2 +- elfi/methods/bo/gpy_regression.py | 58 +++++++------ elfi/methods/mcmc.py | 124 +++++++++++++++++++--------- elfi/methods/parameter_inference.py | 33 ++++---- elfi/methods/posteriors.py | 61 +++++++++----- elfi/visualization/interactive.py | 10 ++- tests/unit/test_bo.py | 29 ++++--- 8 files changed, 205 insertions(+), 113 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index a51cec32..61e6b8d0 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -16,6 +16,7 @@ dev branch (upcoming version) - BO/BOLFI: take advantage of priors - BO/BOLFI: take advantage of seed - BO/BOLFI: improved optimization scheme +- BO/BOLFI: bounds must be a dict 0.5 (2017-05-19) diff --git a/elfi/methods/bo/acquisition.py b/elfi/methods/bo/acquisition.py index 81308d4a..a535b33d 100644 --- a/elfi/methods/bo/acquisition.py +++ b/elfi/methods/bo/acquisition.py @@ -42,7 +42,7 @@ def __init__(self, model, prior=None, n_inits=10, max_opt_iters=1000, noise_cov= if isinstance(noise_cov, (float, int)): noise_cov = np.eye(self.model.input_dim) * noise_cov - elif noise_cov.ndim == 1: + elif np.asarray(noise_cov).ndim == 1: noise_cov = np.diag(noise_cov) self.noise_cov = noise_cov diff --git a/elfi/methods/bo/gpy_regression.py b/elfi/methods/bo/gpy_regression.py index 51c81192..c66d5e82 100644 --- a/elfi/methods/bo/gpy_regression.py +++ b/elfi/methods/bo/gpy_regression.py @@ -18,18 +18,19 @@ class GPyRegression: Parameters ---------- - input_dim : int - number of input dimensions - bounds : tuple of (min, max) tuples - Input space box constraints as a tuple of pairs, one for each input dim - Eg: ((0, 1), (0, 2), (-2, 2)) - If not supplied, defaults to (0, 1) bounds for all dimenstions. - optimizer : string - Optimizer for the GP hyper parameters - Alternatives: "scg", "fmin_tnc", "simplex", "lbfgsb", "lbfgs", "sgd" - See also: paramz.Model.optimize() - max_opt_iters : int - gp : GPy.model.GPRegression instance + parameter_names : list of str, optional + Names of parameter nodes. If None, sets dimension to 1. + bounds : dict, optional + The region where to estimate the posterior for each parameter in + model.parameters. + `{'parameter_name':(lower, upper), ... }` + If not supplied, defaults to (0, 1) bounds for all dimensions. + optimizer : string, optional + Optimizer for the GP hyper parameters + Alternatives: "scg", "fmin_tnc", "simplex", "lbfgsb", "lbfgs", "sgd" + See also: paramz.Model.optimize() + max_opt_iters : int, optional + gp : GPy.model.GPRegression instance, optional **gp_params kernel : GPy.Kern noise_var : float @@ -37,24 +38,33 @@ class GPyRegression: """ - def __init__(self, input_dim=None, bounds=None, optimizer="scg", max_opt_iters=50, + def __init__(self, parameter_names=None, bounds=None, optimizer="scg", max_opt_iters=50, gp=None, **gp_params): - if not input_dim and not bounds: + if parameter_names is None: input_dim = 1 + elif isinstance(parameter_names, (list, tuple)): + input_dim = len(parameter_names) + else: + raise ValueError("Keyword `parameter_names` must be a list of strings") - if not input_dim: - input_dim = len(bounds) - - if not bounds: - logger.warning('Parameter bounds not specified. Using [0,1] for each ' - 'parameter.') - bounds = [(0,1)] * input_dim - - if len(bounds) != input_dim: - raise ValueError("Number of bounds({}) does not match input dimension ({})." + if bounds is None: + logger.warning('Parameter bounds not specified. Using [0,1] for each parameter.') + bounds = [(0, 1)] * input_dim + elif len(bounds) != input_dim: + raise ValueError('Length of `bounds` ({}) does not match the length of `parameter_names` ({}).' .format(input_dim, len(bounds))) + elif isinstance(bounds, dict): + if len(bounds) == 1: # might be the case parameter_names=None + bounds = [bounds[n] for n in bounds.keys()] + else: + # turn bounds dict into a list in the same order as parameter_names + bounds = [bounds[n] for n in parameter_names] + else: + raise ValueError("Keyword `bounds` must be a dictionary " + "`{'parameter_name': (lower, upper), ... }`") + self.input_dim = input_dim self.bounds = bounds diff --git a/elfi/methods/mcmc.py b/elfi/methods/mcmc.py index 4e1cc2fb..5fd34f48 100644 --- a/elfi/methods/mcmc.py +++ b/elfi/methods/mcmc.py @@ -106,7 +106,7 @@ def gelman_rubin(chains): def nuts(n_iter, params0, target, grad_target, n_adapt=None, target_prob=0.6, - max_depth=5, seed=0, info_freq=100): + max_depth=5, seed=0, info_freq=100, max_retry_inits=10): """No-U-Turn Sampler, an improved version of the Hamiltonian (Markov Chain) Monte Carlo sampler. Based on Algorithm 6 in @@ -132,12 +132,15 @@ def nuts(n_iter, params0, target, grad_target, n_adapt=None, target_prob=0.6, Seed for pseudo-random number generator. info_freq : int, optional How often to log progress to loglevel INFO. + max_retry_inits : int, optional + How many times to retry finding initial stepsize (if stepped outside allowed region). Returns ------- samples : np.array Samples from the MCMC algorithm, including those during adaptation. """ + # TODO: consider transforming parameters to allowed region to increase acceptance ratio random_state = np.random.RandomState(seed) n_adapt = n_adapt or n_iter // 2 @@ -147,32 +150,48 @@ def nuts(n_iter, params0, target, grad_target, n_adapt=None, target_prob=0.6, # ******************************** # Find reasonable initial stepsize # ******************************** - stepsize = 1. - momentum0 = random_state.randn(*params0.shape) - grad0 = grad_target(params0) - - # leapfrog - momentum1 = momentum0 + 0.5 * stepsize * grad0 - params1 = params0 + stepsize * momentum1 - momentum1 += 0.5 * stepsize * grad_target(params1) - - joint0 = target(params0) - 0.5 * momentum0.dot(momentum0) - joint1 = target(params1) - 0.5 * momentum1.dot(momentum1) - - plusminus = 1 if np.exp(joint1 - joint0) > 0.5 else -1 - factor = 2. if plusminus==1 else 0.5 - while factor * np.exp(plusminus * (joint1 - joint0)) > 1.: - stepsize *= factor - if stepsize == 0. or stepsize > 1e7: # bounds as in STAN - raise SystemExit("NUTS: Found invalid stepsize {}.".format(stepsize)) + init_tries = 0 + target0 = target(params0) + if np.isinf(target0): + raise ValueError("Bad initialization point {}, logpdf -> -inf.".format(params0)) + + while init_tries < max_retry_inits: # might end in region unallowed by priors + stepsize = 1. + init_tries += 1 + momentum0 = random_state.randn(*params0.shape) + grad0 = grad_target(params0) # leapfrog momentum1 = momentum0 + 0.5 * stepsize * grad0 params1 = params0 + stepsize * momentum1 momentum1 += 0.5 * stepsize * grad_target(params1) + joint0 = target0 - 0.5 * momentum0.dot(momentum0) joint1 = target(params1) - 0.5 * momentum1.dot(momentum1) + plusminus = 1 if np.exp(joint1 - joint0) > 0.5 else -1 + factor = 2. if plusminus==1 else 0.5 + while factor * np.exp(plusminus * (joint1 - joint0)) > 1.: + stepsize *= factor + if stepsize == 0. or stepsize > 1e7: # bounds as in STAN + raise SystemExit("NUTS: Found invalid stepsize {}.".format(stepsize)) + + # leapfrog + momentum1 = momentum0 + 0.5 * stepsize * grad0 + params1 = params0 + stepsize * momentum1 + momentum1 += 0.5 * stepsize * grad_target(params1) + + joint1 = target(params1) - 0.5 * momentum1.dot(momentum1) + if np.isinf(joint1): + break + + if np.isfinite(joint1): # acceptable + break + else: + if init_tries == max_retry_inits: + raise ValueError("Problem initializing with point {}.".format(params0)) + logger.debug("NUTS: Problem initializing. Retrying {}/{}".format(init_tries, max_retry_inits)) + logger.debug("{}: Set initial stepsize {}.".format(__name__, stepsize)) # Some parameters from the NUTS paper, used for adapting the stepsize @@ -189,6 +208,7 @@ def nuts(n_iter, params0, target, grad_target, n_adapt=None, target_prob=0.6, samples = np.empty((n_iter+1,) + params0.shape) samples[0, :] = params0 n_diverged = 0 # counter for proposals whose error diverged + n_outside = 0 # counter for proposals outside priors (pdf=0) n_total = 0 # total number of proposals for ii in range(1, n_iter+1): @@ -208,19 +228,21 @@ def nuts(n_iter, params0, target, grad_target, n_adapt=None, target_prob=0.6, while all_ok and depth <= max_depth: direction = 1 if random_state.rand() < 0.5 else -1 if direction == -1: - params_left, momentum_left, _, _, params1, n_sub, sub_ok, mh_ratio, n_steps, n_diverged1 \ - = _build_tree_nuts(params_left, momentum_left, log_slicevar, -stepsize, depth, \ - log_joint0, target, grad_target, random_state) + params_left, momentum_left, _, _, params1, n_sub, sub_ok, mh_ratio, n_steps, is_div, is_out \ + = _build_tree_nuts(params_left, momentum_left, log_slicevar, -stepsize, depth, \ + log_joint0, target, grad_target, random_state) else: - _, _, params_right, momentum_right, params1, n_sub, sub_ok, mh_ratio, n_steps, n_diverged1 \ - = _build_tree_nuts(params_right, momentum_right, log_slicevar, stepsize, depth, \ - log_joint0, target, grad_target, random_state) + _, _, params_right, momentum_right, params1, n_sub, sub_ok, mh_ratio, n_steps, is_div, is_out \ + = _build_tree_nuts(params_right, momentum_right, log_slicevar, stepsize, depth, \ + log_joint0, target, grad_target, random_state) if sub_ok == 1: if random_state.rand() < float(n_sub) / n_ok: samples[ii, :] = params1 # accept proposal n_ok += n_sub - n_diverged += n_diverged1 + if not is_out: # params1 outside allowed region; don't count this as diverging error + n_diverged += is_div + n_outside += is_out n_total += n_steps all_ok = sub_ok and ((params_right - params_left).dot(momentum_left) >= 0) \ and ((params_right - params_left).dot(momentum_right) >= 0) @@ -236,9 +258,10 @@ def nuts(n_iter, params0, target, grad_target, n_adapt=None, target_prob=0.6, log_avg_stepsize = ii**discount * log_stepsize + (1. - ii**discount) * log_avg_stepsize stepsize = np.exp(log_stepsize) - elif ii == n_adapt + 1: # final stepsize - stepsize = np.exp(log_avg_stepsize) + elif ii == n_adapt + 1: # adaptation/warmup finished + stepsize = np.exp(log_avg_stepsize) # final stepsize n_diverged = 0 + n_outside = 0 n_total = 0 logger.info("NUTS: Adaptation/warmup finished. Sampling...") logger.debug("{}: Set final stepsize {}.".format(__name__, stepsize)) @@ -246,8 +269,14 @@ def nuts(n_iter, params0, target, grad_target, n_adapt=None, target_prob=0.6, if ii % info_freq == 0 and ii < n_iter: logger.info("NUTS: Iterations performed: {}/{}...".format(ii, n_iter)) - logger.info("NUTS: Acceptance ratio: {:.3f}, Diverged proposals after warmup (i.e. n_adapt={} steps): {}" - .format(float(n_iter - n_adapt) / n_total, n_adapt, n_diverged)) + info_str = "NUTS: Acceptance ratio: {:.3f}".format(float(n_iter - n_adapt) / n_total) + if n_outside > 0: + info_str += ". After warmup {} proposals were outside of the region allowed by priors and " \ + "rejected, decreasing acceptance ratio.".format(n_outside) + logger.info(info_str) + + if n_diverged > 0: + logger.warning("NUTS: Diverged proposals after warmup (i.e. n_adapt={} steps): {}" .format(n_adapt, n_diverged)) return samples[1:, :] @@ -269,21 +298,34 @@ def _build_tree_nuts(params, momentum, log_slicevar, step, depth, log_joint0, log_joint = target(params1) - 0.5 * momentum1.dot(momentum1) n_ok = float(log_slicevar <= log_joint) sub_ok = log_slicevar < (1000. + log_joint) # check for diverging error + is_out = False + if not sub_ok: + if np.isinf(target(params1)): # logpdf(params1) = -inf i.e. pdf(params1) = 0 i.e. not allowed + is_out = True + else: + logger.debug("NUTS: Diverging error: log_joint={}, params={}, params1={}, momentum={}, momentum1={}" + ".".format(log_joint, params, params1, momentum, momentum1)) + mh_ratio = 0. # reject + else: + mh_ratio = min(1., np.exp(log_joint - log_joint0)) - mh_ratio = min(1., np.exp(log_joint - log_joint0)) - return params1, momentum1, params1, momentum1, params1, n_ok, sub_ok, mh_ratio, 1., not sub_ok + return params1, momentum1, params1, momentum1, params1, n_ok, sub_ok, mh_ratio, 1., not sub_ok, is_out else: # Recursion to build subtrees, doubling size - params_left, momentum_left, params_right, momentum_right, params1, n_sub, sub_ok, mh_ratio, n_steps, n_diverged \ - = _build_tree_nuts(params, momentum, log_slicevar, step, depth-1, log_joint0, target, grad_target, random_state) - if sub_ok: + params_left, momentum_left, params_right, momentum_right, params1, n_sub, sub_ok, \ + mh_ratio, n_steps, is_div, is_out = _build_tree_nuts(params, momentum, \ + log_slicevar, step, depth-1, log_joint0, target, grad_target, random_state) + + if sub_ok: # recurse further if step < 0: - params_left, momentum_left, _, _, params2, n_sub2, sub_ok, mh_ratio2, n_steps2, n_diverged1 \ - = _build_tree_nuts(params_left, momentum_left, log_slicevar, step, depth-1, log_joint0, target, grad_target, random_state) + params_left, momentum_left, _, _, params2, n_sub2, sub_ok, mh_ratio2, n_steps2, is_div, \ + is_out = _build_tree_nuts(params_left, momentum_left, log_slicevar, \ + step, depth-1, log_joint0, target, grad_target, random_state) else: - _, _, params_right, momentum_right, params2, n_sub2, sub_ok, mh_ratio2, n_steps2, n_diverged1 \ - = _build_tree_nuts(params_right, momentum_right, log_slicevar, step, depth-1, log_joint0, target, grad_target, random_state) + _, _, params_right, momentum_right, params2, n_sub2, sub_ok, mh_ratio2, n_steps2, is_div, \ + is_out = _build_tree_nuts(params_right, momentum_right, log_slicevar, \ + step, depth-1, log_joint0, target, grad_target, random_state) if n_sub2 > 0: if float(n_sub2) / (n_sub + n_sub2) > random_state.rand(): @@ -293,9 +335,9 @@ def _build_tree_nuts(params, momentum, log_slicevar, step, depth, log_joint0, sub_ok = sub_ok and ((params_right - params_left).dot(momentum_left) >= 0) \ and ((params_right - params_left).dot(momentum_right) >= 0) n_sub += n_sub2 - n_diverged += n_diverged1 - return params_left, momentum_left, params_right, momentum_right, params1, n_sub, sub_ok, mh_ratio, n_steps, n_diverged + return params_left, momentum_left, params_right, momentum_right, params1, n_sub, sub_ok, \ + mh_ratio, n_steps, is_div, is_out def metropolis(n_samples, params0, target, sigma_proposals, seed=0): diff --git a/elfi/methods/parameter_inference.py b/elfi/methods/parameter_inference.py index f451e3f2..d943f9ac 100644 --- a/elfi/methods/parameter_inference.py +++ b/elfi/methods/parameter_inference.py @@ -766,16 +766,9 @@ def __init__(self, model, target_name=None, batch_size=1, initial_evidence=None, super(BayesianOptimization, self).__init__(model, output_names, batch_size=batch_size, **kwargs) - if not isinstance(bounds, dict): - raise ValueError("Keyword `bounds` must be a dictionary " - "`{'parameter_name': (lower, upper), ... }`") - - # turn bounds dict into a list in the same order as parameter_names - bounds = [bounds[n] for n in model.parameter_names] - self.target_name = target_name target_model = \ - target_model or GPyRegression(len(self.model.parameter_names), bounds=bounds) + target_model or GPyRegression(self.model.parameter_names, bounds=bounds) # Some sensibility limit for starting GP regression n_initial_required = max(10, 2**target_model.input_dim + 1) @@ -889,7 +882,11 @@ def _report_batch(self, batch_index, params, distances): logger.debug(str) def plot_state(self, **options): - # Plot the GP surface + """Plot the GP surface + + Currently supports only 2D cases. + """ + f = plt.gcf() if len(f.axes) < 2: f, _ = plt.subplots(1,2, figsize=(13,6), sharex='row', sharey='row') @@ -905,9 +902,10 @@ def plot_state(self, **options): axes=f.axes[0], **options) # Draw the latest acquisitions - point = gp._gp.X[-1, :] - if len(gp._gp.X) > 1: - f.axes[1].scatter(*point, color='red') + if options.get('interactive'): + point = gp._gp.X[-1, :] + if len(gp._gp.X) > 1: + f.axes[1].scatter(*point, color='red') displays = [gp._gp] @@ -1050,19 +1048,26 @@ def sample(self, n_samples, warmup=None, n_chains=4, threshold=None, initials=No raise ValueError("The shape of initials must be (n_chains, n_params).") else: # TODO: now GPy specific - inds = np.argsort(self.target_model._gp.Y[:,0])[:n_chains] + inds = np.argsort(self.target_model._gp.Y[:,0]) initials = np.asarray(self.target_model._gp.X[inds]) self.target_model.is_sampling = True # enables caching for default RBF kernel random_state = np.random.RandomState(self.seed) tasks_ids = [] + ii_initial = 0 # sampling is embarrassingly parallel, so depending on self.client this may parallelize for ii in range(n_chains): seed = get_sub_seed(random_state, ii) - tasks_ids.append(self.client.apply(mcmc.nuts, n_samples, initials[ii], posterior.logpdf, + while np.isinf(posterior.logpdf(initials[ii_initial])): # discard bad initialization points + ii_initial += 1 + if ii_initial == len(inds): + raise ValueError("Cannot find enough initialization points!") + + tasks_ids.append(self.client.apply(mcmc.nuts, n_samples, initials[ii_initial], posterior.logpdf, posterior.gradient_logpdf, n_adapt=warmup, seed=seed, **kwargs)) + ii_initial += 1 # get results from completed tasks or run sampling (client-specific) chains = [] diff --git a/elfi/methods/posteriors.py b/elfi/methods/posteriors.py index dfd6647d..8875ae79 100644 --- a/elfi/methods/posteriors.py +++ b/elfi/methods/posteriors.py @@ -199,26 +199,43 @@ def _within_bounds(self, x): logical *= (x[:, i] <= self.model.bounds[i][1]) return logical - def plot(self): - if len(self.model.bounds) == 1: - mn = self.model.bounds[0][0] - mx = self.model.bounds[0][1] - dx = (mx - mn) / 200.0 - x = np.arange(mn, mx, dx) - pd = np.zeros(len(x)) - for i in range(len(x)): - pd[i] = self.pdf([x[i]]) - plt.figure() - plt.plot(x, pd) - plt.xlim(mn, mx) - plt.ylim(0.0, max(pd)*1.05) - plt.show() - - elif len(self.model.bounds) == 2: - x, y = np.meshgrid(np.linspace(*self.model.bounds[0]), np.linspace(*self.model.bounds[1])) - z = (np.vectorize(lambda a,b: self.pdf(np.array([a, b]))))(x, y) - plt.contour(x, y, z) - plt.show() - + def plot(self, logpdf=False): + """Plot the posterior pdf. + + Currently only supports 1 and 2 dimensional cases. + + Parameters + ---------- + logpdf : bool + Whether to plot logpdf instead of pdf. + """ + if logpdf: + fun = self.logpdf else: - raise NotImplementedError("Currently unsupported for dim > 2") + fun = self.pdf + + with np.warnings.catch_warnings(): + np.warnings.filterwarnings('ignore') + + if len(self.model.bounds) == 1: + mn = self.model.bounds[0][0] + mx = self.model.bounds[0][1] + dx = (mx - mn) / 200.0 + x = np.arange(mn, mx, dx) + pd = np.zeros(len(x)) + for i in range(len(x)): + pd[i] = fun([x[i]]) + plt.figure() + plt.plot(x, pd) + plt.xlim(mn, mx) + plt.ylim(min(pd)*1.05, max(pd)*1.05) + plt.show() + + elif len(self.model.bounds) == 2: + x, y = np.meshgrid(np.linspace(*self.model.bounds[0]), np.linspace(*self.model.bounds[1])) + z = (np.vectorize(lambda a,b: fun(np.array([a, b]))))(x, y) + plt.contour(x, y, z) + plt.show() + + else: + raise NotImplementedError("Currently unsupported for dim > 2") diff --git a/elfi/visualization/interactive.py b/elfi/visualization/interactive.py index 8af1a4b4..684f2259 100644 --- a/elfi/visualization/interactive.py +++ b/elfi/visualization/interactive.py @@ -8,6 +8,9 @@ def plot_sample(samples, nodes=None, n=-1, displays=None, **options): + """ + Experimental, only dims 1-2 supported. + """ axes = _prepare_axes(options) nodes = nodes or sorted(samples.keys())[:2] @@ -61,6 +64,9 @@ def _prepare_axes(options): def draw_contour(fn, bounds, nodes=None, points=None, title=None, **options): + """ + Experimental, only 2D supported. + """ ax = get_axes(**options) x, y = np.meshgrid(np.linspace(*bounds[0]), np.linspace(*bounds[1])) @@ -78,7 +84,9 @@ def draw_contour(fn, bounds, nodes=None, points=None, title=None, **options): logger.warning('Could not draw a contour plot') if points is not None: plt.scatter(points[:-1,0], points[:-1,1]) - plt.scatter(points[-1,0], points[-1,1], color='r') + if options.get('interactive'): + plt.scatter(points[-1,0], points[-1,1], color='r') + plt.xlim(bounds[0]) plt.ylim(bounds[1]) diff --git a/tests/unit/test_bo.py b/tests/unit/test_bo.py index 79339b4f..dc743597 100644 --- a/tests/unit/test_bo.py +++ b/tests/unit/test_bo.py @@ -45,10 +45,11 @@ def test_acquisition(): n_params = 2 n = 10 n2 = 5 - bounds = [[-2, 3], [5, 6]] - target_model = elfi.methods.bo.gpy_regression.GPyRegression(n_params, bounds=bounds) - x1 = np.random.uniform(*bounds[0], n) - x2 = np.random.uniform(*bounds[1], n) + parameter_names = ['a', 'b'] + bounds = {'a':[-2, 3], 'b':[5, 6]} + target_model = elfi.methods.bo.gpy_regression.GPyRegression(parameter_names, bounds=bounds) + x1 = np.random.uniform(*bounds['a'], n) + x2 = np.random.uniform(*bounds['b'], n) x = np.column_stack((x1, x2)) y = np.random.rand(n) target_model.update(x, y) @@ -67,8 +68,8 @@ def test_acquisition(): acquisition_method = elfi.methods.bo.acquisition.LCBSC(target_model, noise_cov=acq_noise_cov) new = acquisition_method.acquire(n2, t=t) assert new.shape == (n2, n_params) - assert np.all((new[:, 0] >= bounds[0][0]) & (new[:, 0] <= bounds[0][1])) - assert np.all((new[:, 1] >= bounds[1][0]) & (new[:, 1] <= bounds[1][1])) + assert np.all((new[:, 0] >= bounds['a'][0]) & (new[:, 0] <= bounds['a'][1])) + assert np.all((new[:, 1] >= bounds['b'][0]) & (new[:, 1] <= bounds['b'][1])) # check acquisition with diagonal covariance acq_noise_cov = np.random.uniform(0, 5, size=2) @@ -76,8 +77,8 @@ def test_acquisition(): acquisition_method = elfi.methods.bo.acquisition.LCBSC(target_model, noise_cov=acq_noise_cov) new = acquisition_method.acquire(n2, t=t) assert new.shape == (n2, n_params) - assert np.all((new[:, 0] >= bounds[0][0]) & (new[:, 0] <= bounds[0][1])) - assert np.all((new[:, 1] >= bounds[1][0]) & (new[:, 1] <= bounds[1][1])) + assert np.all((new[:, 0] >= bounds['a'][0]) & (new[:, 0] <= bounds['a'][1])) + assert np.all((new[:, 1] >= bounds['b'][0]) & (new[:, 1] <= bounds['b'][1])) # check acquisition with arbitrary covariance matrix acq_noise_cov = np.random.rand(n_params, n_params) * 0.5 @@ -87,5 +88,13 @@ def test_acquisition(): acquisition_method = elfi.methods.bo.acquisition.LCBSC(target_model, noise_cov=acq_noise_cov) new = acquisition_method.acquire(n2, t=t) assert new.shape == (n2, n_params) - assert np.all((new[:, 0] >= bounds[0][0]) & (new[:, 0] <= bounds[0][1])) - assert np.all((new[:, 1] >= bounds[1][0]) & (new[:, 1] <= bounds[1][1])) + assert np.all((new[:, 0] >= bounds['a'][0]) & (new[:, 0] <= bounds['a'][1])) + assert np.all((new[:, 1] >= bounds['b'][0]) & (new[:, 1] <= bounds['b'][1])) + + # test Uniform Acquisition + t = 1 + acquisition_method = elfi.methods.bo.acquisition.UniformAcquisition(target_model, noise_cov=acq_noise_cov) + new = acquisition_method.acquire(n2, t=t) + assert new.shape == (n2, n_params) + assert np.all((new[:, 0] >= bounds['a'][0]) & (new[:, 0] <= bounds['a'][1])) + assert np.all((new[:, 1] >= bounds['b'][0]) & (new[:, 1] <= bounds['b'][1])) From 75affdf5d338b8ff270835eb36e2882548a05490 Mon Sep 17 00:00:00 2001 From: Henri Vuollekoski Date: Tue, 27 Jun 2017 17:08:44 +0300 Subject: [PATCH 24/38] Refine NUTS setting initial stepsize (#190) * Refine NUTS setting initial stepsize * Remove np.inf -> -300 --- elfi/methods/mcmc.py | 72 ++++++++++++++++------------- elfi/methods/parameter_inference.py | 2 +- 2 files changed, 40 insertions(+), 34 deletions(-) diff --git a/elfi/methods/mcmc.py b/elfi/methods/mcmc.py index 5fd34f48..9c4c9054 100644 --- a/elfi/methods/mcmc.py +++ b/elfi/methods/mcmc.py @@ -5,7 +5,7 @@ logger = logging.getLogger(__name__) -# TODO: parallel chains, combine ESS and Rhat?, total ratio +# TODO: combine ESS and Rhat?, consider transforming parameters to allowed region to increase acceptance ratio def eff_sample_size(chains): @@ -106,7 +106,7 @@ def gelman_rubin(chains): def nuts(n_iter, params0, target, grad_target, n_adapt=None, target_prob=0.6, - max_depth=5, seed=0, info_freq=100, max_retry_inits=10): + max_depth=5, seed=0, info_freq=100, max_retry_inits=20, stepsize=None): """No-U-Turn Sampler, an improved version of the Hamiltonian (Markov Chain) Monte Carlo sampler. Based on Algorithm 6 in @@ -133,48 +133,63 @@ def nuts(n_iter, params0, target, grad_target, n_adapt=None, target_prob=0.6, info_freq : int, optional How often to log progress to loglevel INFO. max_retry_inits : int, optional - How many times to retry finding initial stepsize (if stepped outside allowed region). + How many times to retry finding initial stepsize (if stepped outside allowed region). + stepsize : float, optional + Initial stepsize (will be still adapted). Defaults to finding by trial and error. Returns ------- samples : np.array Samples from the MCMC algorithm, including those during adaptation. """ - # TODO: consider transforming parameters to allowed region to increase acceptance ratio random_state = np.random.RandomState(seed) - n_adapt = n_adapt or n_iter // 2 + n_adapt = n_adapt if n_adapt is not None else n_iter // 2 logger.info("NUTS: Performing {} iterations with {} adaptation steps.".format(n_iter, n_adapt)) - # ******************************** - # Find reasonable initial stepsize - # ******************************** - init_tries = 0 target0 = target(params0) if np.isinf(target0): - raise ValueError("Bad initialization point {}, logpdf -> -inf.".format(params0)) + raise ValueError("NUTS: Bad initialization point {}, logpdf -> -inf.".format(params0)) - while init_tries < max_retry_inits: # might end in region unallowed by priors - stepsize = 1. - init_tries += 1 - momentum0 = random_state.randn(*params0.shape) + # ******************************** + # Find reasonable initial stepsize + # ******************************** + if stepsize is None: grad0 = grad_target(params0) + logger.debug("NUTS: Trying to find initial stepsize from point {} with gradient {}.".format(params0, grad0)) + init_tries = 0 + while init_tries < max_retry_inits: # might step into region unallowed by priors + stepsize = np.exp(-init_tries) + init_tries += 1 + momentum0 = random_state.randn(*params0.shape) - # leapfrog - momentum1 = momentum0 + 0.5 * stepsize * grad0 - params1 = params0 + stepsize * momentum1 - momentum1 += 0.5 * stepsize * grad_target(params1) + # leapfrog + momentum1 = momentum0 + 0.5 * stepsize * grad0 + params1 = params0 + stepsize * momentum1 + momentum1 += 0.5 * stepsize * grad_target(params1) + + joint0 = target0 - 0.5 * momentum0.dot(momentum0) + joint1 = target(params1) - 0.5 * momentum1.dot(momentum1) - joint0 = target0 - 0.5 * momentum0.dot(momentum0) - joint1 = target(params1) - 0.5 * momentum1.dot(momentum1) + if np.isfinite(joint1): + break + else: + if init_tries == max_retry_inits: + raise ValueError("NUTS: Cannot find acceptable stepsize starting from point {}. All " + "trials ended in region with 0 probability.".format(params0)) + # logger.debug("momentum0 {}, momentum1 {}, params1 {}, joint0 {}, joint1 {}" + # .format(momentum0, momentum1, params1, joint0, joint1)) + logger.debug("NUTS: Problem finding acceptable stepsize, now {}. Retrying {}/{}." + .format(stepsize, init_tries, max_retry_inits)) plusminus = 1 if np.exp(joint1 - joint0) > 0.5 else -1 factor = 2. if plusminus==1 else 0.5 while factor * np.exp(plusminus * (joint1 - joint0)) > 1.: stepsize *= factor if stepsize == 0. or stepsize > 1e7: # bounds as in STAN - raise SystemExit("NUTS: Found invalid stepsize {}.".format(stepsize)) + raise SystemExit("NUTS: Found invalid stepsize {} starting from point {}." + .format(stepsize, params0)) # leapfrog momentum1 = momentum0 + 0.5 * stepsize * grad0 @@ -182,17 +197,8 @@ def nuts(n_iter, params0, target, grad_target, n_adapt=None, target_prob=0.6, momentum1 += 0.5 * stepsize * grad_target(params1) joint1 = target(params1) - 0.5 * momentum1.dot(momentum1) - if np.isinf(joint1): - break - - if np.isfinite(joint1): # acceptable - break - else: - if init_tries == max_retry_inits: - raise ValueError("Problem initializing with point {}.".format(params0)) - logger.debug("NUTS: Problem initializing. Retrying {}/{}".format(init_tries, max_retry_inits)) - logger.debug("{}: Set initial stepsize {}.".format(__name__, stepsize)) + logger.debug("NUTS: Set initial stepsize {}.".format(stepsize)) # Some parameters from the NUTS paper, used for adapting the stepsize target_stepsize = np.log(10. * stepsize) @@ -248,7 +254,7 @@ def nuts(n_iter, params0, target, grad_target, n_adapt=None, target_prob=0.6, and ((params_right - params_left).dot(momentum_right) >= 0) depth += 1 if depth > max_depth: - logger.debug("{}: Maximum recursion depth {} exceeded.".format(__name__, max_depth)) + logger.debug("NUTS: Maximum recursion depth {} exceeded.".format(max_depth)) # adjust stepsize according to target acceptance ratio if ii <= n_adapt: @@ -264,7 +270,7 @@ def nuts(n_iter, params0, target, grad_target, n_adapt=None, target_prob=0.6, n_outside = 0 n_total = 0 logger.info("NUTS: Adaptation/warmup finished. Sampling...") - logger.debug("{}: Set final stepsize {}.".format(__name__, stepsize)) + logger.debug("NUTS: Set final stepsize {}.".format(stepsize)) if ii % info_freq == 0 and ii < n_iter: logger.info("NUTS: Iterations performed: {}/{}...".format(ii, n_iter)) diff --git a/elfi/methods/parameter_inference.py b/elfi/methods/parameter_inference.py index d943f9ac..37ce1271 100644 --- a/elfi/methods/parameter_inference.py +++ b/elfi/methods/parameter_inference.py @@ -1063,7 +1063,7 @@ def sample(self, n_samples, warmup=None, n_chains=4, threshold=None, initials=No while np.isinf(posterior.logpdf(initials[ii_initial])): # discard bad initialization points ii_initial += 1 if ii_initial == len(inds): - raise ValueError("Cannot find enough initialization points!") + raise ValueError("BOLFI.sample: Cannot find enough acceptable initialization points!") tasks_ids.append(self.client.apply(mcmc.nuts, n_samples, initials[ii_initial], posterior.logpdf, posterior.gradient_logpdf, n_adapt=warmup, seed=seed, **kwargs)) From 4554ee5efce9434b94792d15b2c112d2881249b8 Mon Sep 17 00:00:00 2001 From: akangasr Date: Wed, 28 Jun 2017 10:35:28 +0300 Subject: [PATCH 25/38] Minor fixes (#186) * Add missing parentheses to function call * Fix dimension of prior-supplied vector when ndim=1 * Fix bounds of user-supplied target_model --- elfi/methods/bo/gpy_regression.py | 4 ++-- elfi/methods/bo/utils.py | 3 +++ elfi/methods/parameter_inference.py | 4 ++++ 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/elfi/methods/bo/gpy_regression.py b/elfi/methods/bo/gpy_regression.py index c66d5e82..7463a2eb 100644 --- a/elfi/methods/bo/gpy_regression.py +++ b/elfi/methods/bo/gpy_regression.py @@ -105,8 +105,8 @@ def predict(self, x, noiseless=False): """ if self._gp is None: # TODO: return from GP mean function if given - return np.zeros(len(x), self.input_dim), \ - np.ones(len(x), self.input_dim) + return np.zeros((x.shape[0], self.input_dim)), \ + np.ones((x.shape[0], self.input_dim)) # Need to cast as 2d array for GPy x = x.reshape((-1, self.input_dim)) diff --git a/elfi/methods/bo/utils.py b/elfi/methods/bo/utils.py index 4b16cf47..ab9e6f6c 100644 --- a/elfi/methods/bo/utils.py +++ b/elfi/methods/bo/utils.py @@ -46,6 +46,9 @@ def minimize(fun, grad, bounds, prior=None, n_start_points=10, maxiter=1000, ran start_points[:, i] = random_state.uniform(*bounds[i], n_start_points) else: start_points = prior.rvs(n_start_points) + if len(start_points.shape) == 1: + # Add possibly missing dimension when ndim=1 + start_points = start_points[:, None] for i in range(ndim): start_points[:, i] = np.clip(start_points[:, i], *bounds[i]) diff --git a/elfi/methods/parameter_inference.py b/elfi/methods/parameter_inference.py index 37ce1271..336d56cf 100644 --- a/elfi/methods/parameter_inference.py +++ b/elfi/methods/parameter_inference.py @@ -770,6 +770,10 @@ def __init__(self, model, target_name=None, batch_size=1, initial_evidence=None, target_model = \ target_model or GPyRegression(self.model.parameter_names, bounds=bounds) + # Fix bounds of user-supplied target_model + if type(target_model.bounds) == dict: + target_model.bounds = [target_model.bounds[k] for k in model.parameter_names] + # Some sensibility limit for starting GP regression n_initial_required = max(10, 2**target_model.input_dim + 1) self._n_precomputed = 0 From fd2e9d3a973942d990781ada3303c8ffca0b79ec Mon Sep 17 00:00:00 2001 From: akangasr Date: Wed, 28 Jun 2017 13:08:14 +0300 Subject: [PATCH 26/38] More minor fixes (#191) * Fix ordering of parameters in error message * Remove default value for 't' in LCBSC --- elfi/methods/bo/acquisition.py | 9 +++------ elfi/methods/bo/gpy_regression.py | 2 +- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/elfi/methods/bo/acquisition.py b/elfi/methods/bo/acquisition.py index a535b33d..13b21a6c 100644 --- a/elfi/methods/bo/acquisition.py +++ b/elfi/methods/bo/acquisition.py @@ -95,7 +95,6 @@ def acquire(self, n_values, pending_locations=None, t=None): ------- locations : 2D np.ndarray of shape (n_values, ...) """ - logger.debug('Acquiring {} values'.format(n_values)) obj = lambda x: self.evaluate(x, t) @@ -123,6 +122,7 @@ def acquire(self, n_values, pending_locations=None, t=None): return x + class LCBSC(AcquisitionBase): """Lower Confidence Bound Selection Criterion. Srinivas et al. call it GP-LCB. @@ -161,7 +161,7 @@ def _beta(self, t): d = self.model.input_dim return 2*np.log(t**(2*d + 2) * np.pi**2 / (3*self.delta)) - def evaluate(self, x, t=None): + def evaluate(self, x, t): """Lower confidence bound selection criterion: mean - sqrt(\beta_t) * std @@ -172,13 +172,10 @@ def evaluate(self, x, t=None): t : int Current iteration (starting from 0). """ - if not isinstance(t, int): - raise ValueError("Parameter 't' should be an integer.") - mean, var = self.model.predict(x, noiseless=True) return mean - np.sqrt(self._beta(t) * var) - def evaluate_gradient(self, x, t=None): + def evaluate_gradient(self, x, t): """Gradient of the lower confidence bound selection criterion. Parameters diff --git a/elfi/methods/bo/gpy_regression.py b/elfi/methods/bo/gpy_regression.py index 7463a2eb..e6346603 100644 --- a/elfi/methods/bo/gpy_regression.py +++ b/elfi/methods/bo/gpy_regression.py @@ -53,7 +53,7 @@ def __init__(self, parameter_names=None, bounds=None, optimizer="scg", max_opt_i bounds = [(0, 1)] * input_dim elif len(bounds) != input_dim: raise ValueError('Length of `bounds` ({}) does not match the length of `parameter_names` ({}).' - .format(input_dim, len(bounds))) + .format(len(bounds), input_dim)) elif isinstance(bounds, dict): if len(bounds) == 1: # might be the case parameter_names=None From 127abd4868cb666da242ae59c6e66fa9ba4b88a8 Mon Sep 17 00:00:00 2001 From: Jarno Lintusaari Date: Wed, 28 Jun 2017 13:16:35 +0300 Subject: [PATCH 27/38] Additions to docs (#188) * Make docs compile, FAQ, and small corrections * Added a few api fixes --- docs/api.rst | 23 ++++++++++++++++++----- docs/conf.py | 4 ++-- docs/faq.rst | 10 +++++++++- docs/good-to-know.rst | 21 ++++++++++++--------- docs/index.rst | 1 + elfi/methods/post_processing.py | 2 +- 6 files changed, 43 insertions(+), 18 deletions(-) diff --git a/docs/api.rst b/docs/api.rst index 162d9e22..78033bed 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -1,5 +1,6 @@ API === + This file describes the classes and methods available in ELFI. Modelling API @@ -61,9 +62,15 @@ Below is a list of inference methods included in ELFI. **Post-processing** +.. currentmodule:: elfi + .. autosummary:: elfi.adjust_posterior - elfi.methods.post_processing.LinearAdjustment + +.. currentmodule:: elfi.methods.post_processing + +.. autosummary:: + LinearAdjustment Other ----- @@ -172,6 +179,9 @@ Inference API classes .. currentmodule:: elfi.methods.results + +**Result objects** + .. autoclass:: OptimizationResult :members: :inherited-members: @@ -188,11 +198,14 @@ Inference API classes :members: :inherited-members: -.. currentmodule:: elfi.methods.post_processing -.. autoclass:: RegressionAdjustment - :members: - :inherited-members: +**Post-processing** + +.. currentmodule:: elfi + +.. automethod:: elfi.adjust_posterior + +.. currentmodule:: elfi.methods.post_processing .. autoclass:: LinearAdjustment :members: diff --git a/docs/conf.py b/docs/conf.py index c555444a..49511b88 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -30,8 +30,8 @@ def __getattr__(cls, name): MOCK_MODULES = ['pygtk', 'gtk', 'gobject', 'argparse', 'numpy', 'pandas', 'scipy', 'unqlite', 'dask', 'distributed', 'distributed.client', 'graphviz', 'matplotlib', 'sobol_seq', 'GPy', 'dask.delayed', 'scipy.optimize', 'scipy.stats', - 'scipy.spatial', 'matplotlib.pyplot', 'numpy.random', 'networkx', - 'ipyparallel', 'numpy.lib', 'numpy.lib.format'] + 'scipy.spatial', 'scipy.sparse', 'matplotlib.pyplot', 'numpy.random', 'networkx', + 'ipyparallel', 'numpy.lib', 'numpy.lib.format', 'sklearn.linear_model'] sys.modules.update((mod_name, Mock()) for mod_name in MOCK_MODULES) html_theme = 'default' diff --git a/docs/faq.rst b/docs/faq.rst index 2d20dfe2..464f2b87 100644 --- a/docs/faq.rst +++ b/docs/faq.rst @@ -1,4 +1,12 @@ Frequently Asked Questions ========================== -TODO +Below are answers to some common questions asked about ELFI. + +*Q: My uniform prior* ``elfi.Prior('uniform', 1, 2)`` *does not seem to be right as it +produces outputs from the interval (1, 3).* + +**A**: The distributions defined by strings are those from ``scipy.stats`` and follow +their definitions. There the uniform distribution uses the location/scale definition, so +the first argument defines the starting point of the interval and the second its length. + diff --git a/docs/good-to-know.rst b/docs/good-to-know.rst index 0d6c04d1..4c468dbd 100644 --- a/docs/good-to-know.rst +++ b/docs/good-to-know.rst @@ -40,14 +40,17 @@ Reusing data The `OutputPool`_ object can be used to store the outputs of any node in the graph. Note however that changing a node in the model will change the outputs of it's child nodes. In -Rejection sampling you can alter any nodes that are children of the nodes in the -`OutputPool`_ and safely reuse the `OutputPool`_ with the modified model. This is -especially handy when saving the simulations and trying out different summaries. - -However the other algorithms will produce biased results if you reuse the `OutputPool`_ -with a modified model. This is because they learn from the previous results and decide -the new parameter values based on them. The Rejection sampling does not suffer from this -because it always samples new parameter values directly from the priors, and therefore -modified distance outputs have no effect to the parameter values of any later simulations. +Rejection sampling you can alter the child nodes of the nodes in the `OutputPool`_ and +safely reuse the `OutputPool`_ with the modified model. This is especially handy when +saving the simulations and trying out different summaries. BOLFI allows you to use the +stored data as initialization data. + +However passing a modified model with the `OutputPool`_ of the original model will produce +biased results in other algorithms besides Rejection sampling. This is because more +advanced algorithms learn from previous results. If the results change in some way, so +will also the following parameter values and thus also their simulations and other nodes +that depend on them. The Rejection sampling does not suffer from this because it always +samples new parameter values directly from the priors, and therefore modified distance +outputs have no effect to the parameter values of any later simulations. .. _`OutputPool`: api.html#elfi.OutputPool \ No newline at end of file diff --git a/docs/index.rst b/docs/index.rst index 6cd9469e..6c16fefa 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -43,6 +43,7 @@ ELFI also has the following non LFI methods: good-to-know quickstart api + faq .. toctree:: :maxdepth: 1 diff --git a/elfi/methods/post_processing.py b/elfi/methods/post_processing.py index 283eebe1..d6921c4e 100644 --- a/elfi/methods/post_processing.py +++ b/elfi/methods/post_processing.py @@ -234,7 +234,7 @@ def adjust_posterior(sample, model, summary_names, Returns ------- - sample + elfi.methods.results.Sample a Sample object with the adjusted posterior Examples From 281384e39b2fdb9b23c76f1c29b88e80426de942 Mon Sep 17 00:00:00 2001 From: Jarno Lintusaari Date: Wed, 28 Jun 2017 13:18:27 +0300 Subject: [PATCH 28/38] Api updates (#189) * Update ElfiModel api. Fix the remove_node method. * Update the ElfiModel api documentation * Fix some tests --- docs/api.rst | 1 - elfi/model/elfi_model.py | 59 ++++++++++++++++++++---- elfi/model/graphical_model.py | 18 +++----- tests/functional/test_post_processing.py | 10 ++-- tests/unit/test_elfi_model.py | 23 ++++++++- 5 files changed, 84 insertions(+), 27 deletions(-) diff --git a/docs/api.rst b/docs/api.rst index 78033bed..2af520bd 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -108,7 +108,6 @@ Modelling API classes .. autoclass:: elfi.ElfiModel :members: - :inherited-members: .. autoclass:: elfi.Constant :members: diff --git a/elfi/model/elfi_model.py b/elfi/model/elfi_model.py index b768f12e..9fa11d20 100644 --- a/elfi/model/elfi_model.py +++ b/elfi/model/elfi_model.py @@ -109,6 +109,7 @@ def copy(self): return copy.copy(self) +# TODO: make a `elfi.new_model` function and move the `set_current` functionality to there class ElfiModel(GraphicalModel): """A generative model for LFI """ @@ -183,13 +184,46 @@ def get_reference(self, name): cls = self.get_node(name)['_class'] return cls.reference(name, self) - def update_node(self, node, updating_node): - # Change the observed data - self.observed.pop(node, None) - if updating_node in self.observed: - self.observed[node] = self.observed.pop(updating_node) + def get_state(self, name): + """Return the state of the node.""" + return self.source_net.node[name] - super(ElfiModel, self).update_node(node, updating_node) + def update_node(self, name, updating_name): + """Updates `node` with `updating_node` in the model. + + The node with name `name` gets the state (operation), parents and observed + data (if applicable) of the updating_node. The updating node is then removed + from the graph. + + Parameters + ---------- + name : str + updating_name : str + """ + + update_observed = False + obs = None + if updating_name in self.observed: + update_observed = True + obs = self.observed.pop(updating_name) + + super(ElfiModel, self).update_node(name, updating_name) + + # Move data to the updated node + if update_observed: + self.observed[name] = obs + + def remove_node(self, name): + """Remove a node from the graph + + Parameters + ---------- + name : str + + """ + if name in self.observed: + self.observed.pop(name) + super(ElfiModel, self).remove_node(name) @property def observed(self): @@ -228,8 +262,15 @@ def parameter_names(self, parameter_names): if len(parameter_names) > 0: raise ValueError('Parameters {} not found from the model'.format(parameter_names)) - def __copy__(self): - kopy = super(ElfiModel, self).__copy__(set_current=False) + def copy(self): + """Return a copy of the ElfiModel instance + + Returns + ------- + ElfiModel + + """ + kopy = super(ElfiModel, self).copy(set_current=False) kopy.computation_context = self.computation_context.copy() kopy.name = "{}_copy_{}".format(self.name, random_name()) return kopy @@ -340,7 +381,7 @@ def parents(self): parents : list List of positional parents """ - return [self.model[p] for p in self.model.parent_names(self.name)] + return [self.model[p] for p in self.model.get_parents(self.name)] @classmethod def reference(cls, name, model): diff --git a/elfi/model/graphical_model.py b/elfi/model/graphical_model.py index 29ed9e01..046453c0 100644 --- a/elfi/model/graphical_model.py +++ b/elfi/model/graphical_model.py @@ -16,7 +16,7 @@ def add_node(self, name, state): self.source_net.add_node(name, attr_dict=state) def remove_node(self, name): - parent_names = self.parent_names(name) + parent_names = self.get_parents(name) self.source_net.remove_node(name) # Remove sole private parents @@ -41,16 +41,13 @@ def set_node(self, name, state): def has_node(self, name): return self.source_net.has_node(name) - def get_state(self, name): - return self.source_net.node[name] - # TODO: deprecated. Incorporate into add_node so that these are not modifiable # This protects the internal state of the ElfiModel so that consistency can be more # easily managed def add_edge(self, parent_name, child_name, param_name=None): # Deprecated. By default, map to a positional parameter of the child if param_name is None: - param_name = len(self.parent_names(child_name)) + param_name = len(self.get_parents(child_name)) if not isinstance(param_name, (int, str)): raise ValueError('Unrecognized type for `param_name` {}. Must be either an ' '`int` for positional parameters or `str` for named ' @@ -86,7 +83,7 @@ def update_node(self, node, updating_node): self.remove_node(updating_node) - def parent_names(self, child_name): + def get_parents(self, child_name): """ Parameters @@ -110,12 +107,11 @@ def nodes(self): """Returns a list of nodes""" return self.source_net.nodes() - def copy(self): - """Returns a copy of the model""" - return self.__copy__() - - def __copy__(self, *args, **kwargs): + def copy(self, *args, **kwargs): kopy = self.__class__(*args, **kwargs) # Copy the source net kopy.source_net = nx.DiGraph(self.source_net) return kopy + + def __copy__(self, *args, **kwargs): + return self.copy() diff --git a/tests/functional/test_post_processing.py b/tests/functional/test_post_processing.py index 91933da1..f5e47031 100644 --- a/tests/functional/test_post_processing.py +++ b/tests/functional/test_post_processing.py @@ -50,7 +50,7 @@ def test_single_parameter_linear_adjustment(): parameter_names=['mu'], summary_names=['S1']) - assert _statistics(adj.outputs['mu']) == (4.9772879640569778, 0.02058680115402544) + assert np.allclose(_statistics(adj.outputs['mu']), (4.9772879640569778, 0.02058680115402544)) # TODO: Use a fixture for the model @@ -92,8 +92,8 @@ def test_nonfinite_values(): parameter_names=['mu'], summary_names=['S1']) - assert _statistics(adj.outputs['mu']) == (4.9772879640569778, - 0.02058680115402544) + assert np.allclose(_statistics(adj.outputs['mu']), (4.9772879640569778, + 0.02058680115402544)) def test_multi_parameter_linear_adjustment(): @@ -121,5 +121,5 @@ def test_multi_parameter_linear_adjustment(): t1_mean, t1_var = (0.51606048286584782, 0.017253007645871756) t2_mean, t2_var = (0.15805189695581101, 0.028004406914362647) - assert _statistics(t1) == (t1_mean, t1_var) - assert _statistics(t2) == (t2_mean, t2_var) + assert np.allclose(_statistics(t1), (t1_mean, t1_var)) + assert np.allclose(_statistics(t2), (t2_mean, t2_var)) diff --git a/tests/unit/test_elfi_model.py b/tests/unit/test_elfi_model.py index 032d1008..85152ff4 100644 --- a/tests/unit/test_elfi_model.py +++ b/tests/unit/test_elfi_model.py @@ -49,6 +49,27 @@ def euclidean_discrepancy(*simulated, observed): return d +class TestElfiModel: + def test_remove_node(self, ma2): + ma2.remove_node('MA2') + + assert not ma2.has_node('MA2') + assert ma2.has_node('t2') + + parents = ma2.get_parents('t2') + # This needs to have at least 2 parents so that the test below makes sense + assert len(parents) > 1 + + ma2.remove_node('t2') + for p in parents: + if p[0] == '_': + assert not ma2.has_node(p) + else: + assert ma2.has_node(p) + + assert 'MA2' not in ma2.observed + + class TestNodeReference: def test_name_argument(self): # This is important because it is used when passing NodeReferences as @@ -107,7 +128,7 @@ def test_become(self, ma2): def test_become_with_priors(self, ma2): parameters = ma2.parameter_names.copy() - parent_names = ma2.parent_names('t1') + parent_names = ma2.get_parents('t1') ma2['t1'].become(elfi.Prior('uniform', 0, model=ma2)) From 89526ba5d6cf1cc367ce524e32a54c83d544babd Mon Sep 17 00:00:00 2001 From: akangasr Date: Wed, 28 Jun 2017 16:45:33 +0300 Subject: [PATCH 29/38] Make gradient function argument optional in minimize (#192) As the argument is not a strict functional requirement for using the minimization interface, it should be optional to allow better using minimize() as a general functionality. --- elfi/methods/bo/acquisition.py | 2 +- elfi/methods/bo/utils.py | 11 +++++++---- elfi/methods/posteriors.py | 2 +- tests/functional/test_inference.py | 8 ++++++-- tests/unit/test_utils.py | 12 ++++++++++-- 5 files changed, 25 insertions(+), 10 deletions(-) diff --git a/elfi/methods/bo/acquisition.py b/elfi/methods/bo/acquisition.py index 13b21a6c..05fa92bb 100644 --- a/elfi/methods/bo/acquisition.py +++ b/elfi/methods/bo/acquisition.py @@ -99,7 +99,7 @@ def acquire(self, n_values, pending_locations=None, t=None): obj = lambda x: self.evaluate(x, t) grad_obj = lambda x: self.evaluate_gradient(x, t) - minloc, minval = minimize(obj, grad_obj, self.model.bounds, self.prior, self.n_inits, self.max_opt_iters) + minloc, minval = minimize(obj, self.model.bounds, grad_obj, self.prior, self.n_inits, self.max_opt_iters) x = np.tile(minloc, (n_values, 1)) # add some noise for more efficient exploration diff --git a/elfi/methods/bo/utils.py b/elfi/methods/bo/utils.py index ab9e6f6c..3fc262e7 100644 --- a/elfi/methods/bo/utils.py +++ b/elfi/methods/bo/utils.py @@ -11,17 +11,17 @@ def stochastic_optimization(fun, bounds, maxiter=1000, polish=True, seed=0): # TODO: allow argument for specifying the optimization algorithm -def minimize(fun, grad, bounds, prior=None, n_start_points=10, maxiter=1000, random_state=None): +def minimize(fun, bounds, grad=None, prior=None, n_start_points=10, maxiter=1000, random_state=None): """ Called to find the minimum of function 'fun'. Parameters ---------- fun : callable Function to minimize. - grad : callable - Gradient of fun. bounds : list of tuples Bounds for each parameter. + grad : callable + Gradient of fun or None. prior : scipy-like distribution object Used for sampling initialization points. If None, samples uniformly. n_start_points : int, optional @@ -57,7 +57,10 @@ def minimize(fun, grad, bounds, prior=None, n_start_points=10, maxiter=1000, ran # Run optimization from each initialization point for i in range(n_start_points): - result = fmin_l_bfgs_b(fun, start_points[i, :], fprime=grad, bounds=bounds, maxiter=maxiter) + if grad is not None: + result = fmin_l_bfgs_b(fun, start_points[i, :], fprime=grad, bounds=bounds, maxiter=maxiter) + else: + result = fmin_l_bfgs_b(fun, start_points[i, :], approx_grad=True, bounds=bounds, maxiter=maxiter) locs.append(result[0]) vals[i] = result[1] diff --git a/elfi/methods/posteriors.py b/elfi/methods/posteriors.py index 8875ae79..a26798a5 100644 --- a/elfi/methods/posteriors.py +++ b/elfi/methods/posteriors.py @@ -59,8 +59,8 @@ def __init__(self, model, threshold=None, prior=None, n_inits=10, max_opt_iters= if self.threshold is None: # TODO: the evidence could be used for a good guess for starting locations minloc, minval = minimize(self.model.predict_mean, - self.model.predictive_gradient_mean, self.model.bounds, + self.model.predictive_gradient_mean, self.prior, self.n_inits, self.max_opt_iters, diff --git a/tests/functional/test_inference.py b/tests/functional/test_inference.py index a14887e9..62f45b88 100644 --- a/tests/functional/test_inference.py +++ b/tests/functional/test_inference.py @@ -123,8 +123,12 @@ def test_BOLFI(): post = bolfi.extract_posterior() # TODO: make cleaner. - post_ml = minimize(post._neg_unnormalized_loglikelihood, post._gradient_neg_unnormalized_loglikelihood, - post.model.bounds, post.prior, post.n_inits, post.max_opt_iters, + post_ml = minimize(post._neg_unnormalized_loglikelihood, + post.model.bounds, + post._gradient_neg_unnormalized_loglikelihood, + post.prior, + post.n_inits, + post.max_opt_iters, random_state=post.random_state)[0] # TODO: Here we cannot use the minimize method due to sharp edges in the posterior. # If a MAP method is implemented, one must be able to set the optimizer and diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py index be84ae0d..989063a1 100644 --- a/tests/unit/test_utils.py +++ b/tests/unit/test_utils.py @@ -17,11 +17,19 @@ def test_stochastic_optimization(): assert abs(val - 0.0) < 1e-5 -def test_minimize(): +def test_minimize_with_known_gradient(): fun = lambda x : x[0]**2 + (x[1]-1)**4 grad = lambda x : np.array([2*x[0], 4*(x[1]-1)**3]) bounds = ((-2, 2), (-2, 3)) - loc, val = minimize(fun, grad, bounds) + loc, val = minimize(fun, bounds, grad) + assert np.isclose(val, 0, atol=0.01) + assert np.allclose(loc, np.array([0, 1]), atol=0.02) + + +def test_minimize_with_approx_gradient(): + fun = lambda x : x[0]**2 + (x[1]-1)**4 + bounds = ((-2, 2), (-2, 3)) + loc, val = minimize(fun, bounds) assert np.isclose(val, 0, atol=0.01) assert np.allclose(loc, np.array([0, 1]), atol=0.02) From c6b753227be65c61e732d52f2cb75d6a57a922e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kusti=20Skyt=C3=A9n?= Date: Thu, 29 Jun 2017 10:24:59 +0300 Subject: [PATCH 30/38] Fix dev installation with conda (#195) --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 59018e1b..9370cb41 100644 --- a/Makefile +++ b/Makefile @@ -99,4 +99,4 @@ install: clean ## install the package to the active Python's site-packages pip install -e . dev: install ## install the development requirements to the active Python's site-packages - pip install -Ur requirements-dev.txt + pip install -r requirements-dev.txt From 9915f5d1ce4ed9f2fecb748ef62a33bd6b4cfc59 Mon Sep 17 00:00:00 2001 From: akangasr Date: Thu, 29 Jun 2017 13:25:26 +0300 Subject: [PATCH 31/38] Make initial samples requirement a recommendation instead (#179) * Fix GPyRegression behavior with zero samples Also, make initial point requirement in BOLFI a recommendation instead of a strict requirement * Add a simple test for BO with 0 initial samples --- elfi/methods/bo/gpy_regression.py | 58 ++++++++++++++++++++++------- elfi/methods/parameter_inference.py | 9 +++-- tests/unit/test_bo.py | 17 +++++++++ 3 files changed, 67 insertions(+), 17 deletions(-) diff --git a/elfi/methods/bo/gpy_regression.py b/elfi/methods/bo/gpy_regression.py index e6346603..1e42bae8 100644 --- a/elfi/methods/bo/gpy_regression.py +++ b/elfi/methods/bo/gpy_regression.py @@ -84,32 +84,58 @@ def __str__(self): def __repr__(self): return self.__str__() + def _check_input(self, x): + """Check and interpret input locations. + + Parameters + ---------- + x : np.array + numpy compatible array of points + if len(x.shape) == 1 will be cast to 2D with x[None, :] + + Returns + ------- + 2D numpy array with shape (n_locations, input_dim) + or raises a ValueError + """ + if type(x) is not np.ndarray: + try: + x = np.array(x) + except: + raise ValueError("Location must be of type interpretable as a numpy array (was {})".format(type(x))) + if len(x.shape) == 1: + # add missing dimension + x = x[None, :] + if x.shape[1] != self.input_dim: + raise ValueError("Location dimensions ({}) must match model dimensions ({})"\ + .format(x.shape[1], self.input_dim)) + return x + def predict(self, x, noiseless=False): """Returns the GP model mean and variance at x. Parameters ---------- x : np.array - numpy (n, input_dim) array of points to evaluate + numpy compatible (n, input_dim) array of points to evaluate + if len(x.shape) == 1 will be cast to 2D with x[None, :] noiseless : bool whether to include the noise variance or not to the returned variance - + Returns ------- tuple GP (mean, var) at x where mean : np.array - with shape (len(x), input_dim) + with shape (x.shape[0], 1) var : np.array - with shape (len(x), input_dim) + with shape (x.shape[0], 1) """ + x = self._check_input(x) if self._gp is None: # TODO: return from GP mean function if given - return np.zeros((x.shape[0], self.input_dim)), \ - np.ones((x.shape[0], self.input_dim)) - - # Need to cast as 2d array for GPy - x = x.reshape((-1, self.input_dim)) + return np.zeros((x.shape[0], 1)), \ + np.ones((x.shape[0], 1)) # direct (=faster) implementation for RBF kernel if self.is_sampling and self._kernel_is_default: @@ -157,19 +183,23 @@ def predictive_gradients(self, x): Parameters ---------- x : np.array - numpy (n, input_dim) array of points to evaluate + numpy compatible (n, input_dim) array of points to evaluate + if len(x.shape) == 1 will be cast to 2D with x[None, :] Returns ------- tuple GP (grad_mean, grad_var) at x where grad_mean : np.array - with shape (len(x), input_dim) + with shape (x.shape[0], input_dim) grad_var : np.array - with shape (len(x), input_dim) + with shape (x.shape[0], input_dim) """ - # Need to cast as 2d array for GPy - x = x.reshape((-1, self.input_dim)) + x = self._check_input(x) + if self._gp is None: + # TODO: return from GP mean function if given + return np.zeros((x.shape[0], self.input_dim)), \ + np.zeros((x.shape[0], self.input_dim)) # direct (=faster) implementation for RBF kernel if self.is_sampling and self._kernel_is_default: diff --git a/elfi/methods/parameter_inference.py b/elfi/methods/parameter_inference.py index 336d56cf..690c06fd 100644 --- a/elfi/methods/parameter_inference.py +++ b/elfi/methods/parameter_inference.py @@ -753,8 +753,8 @@ def __init__(self, model, target_name=None, batch_size=1, initial_evidence=None, model.parameters. `{'parameter_name':(lower, upper), ... }` initial_evidence : int, dict, optional - Number of initial evidence or a precomputed batch dict containing parameter - and discrepancy values + Number of initial evidence or a precomputed batch dict containing parameter + and discrepancy values. Defaults to max(10, 2**model_input_dim + 1). update_interval : int How often to update the GP hyperparameters of the target_model exploration_rate : float @@ -787,8 +787,11 @@ def __init__(self, model, target_name=None, batch_size=1, initial_evidence=None, initial_evidence = len(params) self._n_precomputed = initial_evidence + if initial_evidence < 0: + raise ValueError('Number of initial evidence must be positive or zero (was {})'.format(initial_evidence)) if initial_evidence < n_initial_required: - raise ValueError('Need at least {} initialization points'.format(n_initial_required)) + logger.warning('BOLFI should have at least {} initialization points for reliable initialization (now {})'\ + .format(n_initial_required, initial_evidence)) if initial_evidence % self.batch_size != 0: raise ValueError('Number of initial evidence must be divisible by the batch size') diff --git a/tests/unit/test_bo.py b/tests/unit/test_bo.py index dc743597..85e20f84 100644 --- a/tests/unit/test_bo.py +++ b/tests/unit/test_bo.py @@ -40,6 +40,23 @@ def test_BO(ma2): assert np.array_equal(bo.target_model._gp.X[:n_init, 0], res_init.samples_list[0]) +@pytest.mark.usefixtures('with_all_clients') +def test_BO_works_with_zero_init_samples(ma2): + log_d = elfi.Operation(np.log, ma2['d'], name='log_d') + bounds = {n:(-2, 2) for n in ma2.parameter_names} + bo = elfi.BayesianOptimization(log_d, initial_evidence=0, + update_interval=4, batch_size=2, + bounds=bounds) + assert bo.target_model.n_evidence == 0 + assert bo.n_evidence == 0 + assert bo._n_precomputed == 0 + assert bo.n_initial_evidence == 0 + samples = 4 + bo.infer(samples) + assert bo.target_model.n_evidence == samples + assert bo.n_evidence == samples + assert bo._n_precomputed == 0 + assert bo.n_initial_evidence == 0 def test_acquisition(): n_params = 2 From c61a48cc0bdc8c5f748495a7e2567789cb748cad Mon Sep 17 00:00:00 2001 From: Jarno Lintusaari Date: Fri, 30 Jun 2017 17:19:39 +0300 Subject: [PATCH 32/38] Consistency tests (#197) * SMC fixes - Make SMC consistent across runs with the same computation_context (seed, batch_size etc) - Fix a bug causing SMC to fail with very small sample sizes * BO is now consistent * Add consistency tests when using the same seed * Fix the BOLFI test * Docs: Change emph to tokens * Fix SMC consistency with multiple workers * Refactored BOLFI for consistency * All tests passing * Small changes to GPy regression * Test passing after rebase * Review changes --- docs/developer/architecture.rst | 28 +- elfi/methods/bo/acquisition.py | 187 +++++++------ elfi/methods/bo/gpy_regression.py | 55 ++-- elfi/methods/bo/utils.py | 6 +- elfi/methods/parameter_inference.py | 387 +++++++++++++++------------ elfi/methods/results.py | 7 +- elfi/methods/utils.py | 67 ++++- elfi/utils.py | 6 +- tests/conftest.py | 1 + tests/functional/test_consistency.py | 118 ++++++++ tests/functional/test_inference.py | 9 +- tests/functional/test_randomness.py | 3 + tests/unit/test_bo.py | 40 +-- tests/unit/test_methods.py | 2 +- 14 files changed, 580 insertions(+), 336 deletions(-) create mode 100644 tests/functional/test_consistency.py diff --git a/docs/developer/architecture.rst b/docs/developer/architecture.rst index c3dcd717..1d11af61 100644 --- a/docs/developer/architecture.rst +++ b/docs/developer/architecture.rst @@ -7,32 +7,32 @@ e.g. the inference methods or the data storages. This information is aimed for d and is not essential for using ELFI. We assume the reader is quite familiar with Python and has perhaps already read some of ELFI's source code. -The low level representation of the generative model is a `networkx.DiGraph` with nodes +The low level representation of the generative model is a ``networkx.DiGraph`` with nodes represented as Python dictionaries that are called node state dictionaries. This -representation is held in `ElfiModel.source_net`. Before the generative model can be ran, +representation is held in ``ElfiModel.source_net``. Before the generative model can be ran, it needs to be compiled and loaded with data (e.g. observed data, precomputed data, batch index, batch size etc). The compilation and loading of data is the responsibility of the -`Client` implementation and makes it possible in essence to translate `ElfiModel` to any -kind of computational backend. Finally the class `Executor` is responsible for +``Client`` implementation and makes it possible in essence to translate ``ElfiModel`` to any +kind of computational backend. Finally the class ``Executor`` is responsible for running the compiled and loaded model and producing the outputs of the nodes. A user typically creates this low level representation by working with subclasses of -`NodeReference`. These are easy to use UI classes of ELFI such as the `elfi.Simulator` or -`elfi.Prior`. Under the hood they create proper node state dictionaries stored into the -`source_net`. The callables such as simulators or summaries that the user provides to +``NodeReference``. These are easy to use UI classes of ELFI such as the ``elfi.Simulator`` or +``elfi.Prior``. Under the hood they create proper node state dictionaries stored into the +``source_net``. The callables such as simulators or summaries that the user provides to these classes are called operations. The model graph representation ------------------------------ -The `source_net` is a directed acyclic graph (DAG) and holds the state dictionaries of the +The ``source_net`` is a directed acyclic graph (DAG) and holds the state dictionaries of the nodes and the edges between the nodes. An edge represents a dependency. For example and edge from a prior node to the simulator node represents that the simulator requires a value from the prior to be able to run. The edge name corresponds to a parameter name for the operation, with integer names interpreted as positional parameters. -In the standard compilation process, the `source_net` is augmented with additional nodes +In the standard compilation process, the ``source_net`` is augmented with additional nodes such as batch_size or random_state, that are then added as dependencies for those operations that require them. In addition the state dicts will be turned into either a runnable operation or a precomputed value. @@ -60,7 +60,7 @@ _operation : callable _output : variable Constant output of the node. Can not be used if _operation is present. _class : class - The subclass of `NodeReference` that created the state. + The subclass of ``NodeReference`` that created the state. _stochastic : bool, optional Indicates that the node is stochastic. ELFI will provide a random_state argument for such nodes, which contains a RandomState object for drawing random quantities. @@ -71,13 +71,13 @@ _observable : bool, optional observed data. ELFI will create a corresponding observed node into the compiled graph. These nodes are dependencies of discrepancy nodes. _uses_batch_size : bool, optional - Indicates that the node operation requires `batch_size` as input. A corresponding edge + Indicates that the node operation requires ``batch_size`` as input. A corresponding edge from batch_size node to this node will be added to the compiled graph. _uses_meta : bool, optional Indicates that the node operation requires meta information dictionary about the execution. This includes, model name, batch index and submission index. Useful for e.g. creating informative and unique file names. If the operation is - vectorized with `elfi.tools.vectorize`, then also `index_in_batch` will be added to + vectorized with ``elfi.tools.vectorize``, then also ``index_in_batch`` will be added to the meta information dictionary. _uses_observed : bool, optional Indicates that the node requires the observed data of its parents in the source_net as @@ -91,7 +91,7 @@ The compilation and data loading phases --------------------------------------- The compilation of the computation graph is separated from the loading of the data for -making it possible to reuse the compiled model. The subclasses of the `Loader` class +making it possible to reuse the compiled model. The subclasses of the ``Loader`` class take responsibility of injecting data to the nodes of the compiled model. Examples of -injected data are precomputed values from the `OutputPool`, the current `random_state` and +injected data are precomputed values from the ``OutputPool``, the current ``random_state`` and so forth. diff --git a/elfi/methods/bo/acquisition.py b/elfi/methods/bo/acquisition.py index 05fa92bb..96366b58 100644 --- a/elfi/methods/bo/acquisition.py +++ b/elfi/methods/bo/acquisition.py @@ -1,57 +1,60 @@ import logging import numpy as np -from scipy.stats import uniform, multivariate_normal, truncnorm +from scipy.stats import uniform, truncnorm from elfi.methods.bo.utils import minimize logger = logging.getLogger(__name__) -# TODO: make a faster optimization method utilizing parallelization (see e.g. GPyOpt) - class AcquisitionBase: """All acquisition functions are assumed to fulfill this interface. - Gaussian noise ~N(0, noise_cov) is added to the acquired points. By default, noise_cov=0. + Gaussian noise ~N(0, self.noise_var) is added to the acquired points. By default, + noise_var=0. You can define a different variance for the separate dimensions. - Parameters - ---------- - model : an object with attributes - input_dim : int - bounds : tuple of length 'input_dim' of tuples (min, max) - and methods - evaluate(x) : function that returns model (mean, var, std) - prior - By default uniform distribution within model bounds. - n_inits : int, optional - Number of initialization points in internal optimization. - max_opt_iters : int, optional - Max iterations to optimize when finding the next point. - noise_cov : float or np.array, optional - Covariance of the added noise. If float, multiplied by identity matrix. - seed : int """ - def __init__(self, model, prior=None, n_inits=10, max_opt_iters=1000, noise_cov=0., seed=0): - self.model = model - self.n_inits = n_inits - self.max_opt_iters = int(max_opt_iters) - - self.prior = prior + def __init__(self, model, prior=None, n_inits=10, max_opt_iters=1000, noise_var=None, + exploration_rate=10, seed=None): + """ - if isinstance(noise_cov, (float, int)): - noise_cov = np.eye(self.model.input_dim) * noise_cov - elif np.asarray(noise_cov).ndim == 1: - noise_cov = np.diag(noise_cov) - self.noise_cov = noise_cov + Parameters + ---------- + model : an object with attributes + input_dim : int + bounds : tuple of length 'input_dim' of tuples (min, max) + and methods + evaluate(x) : function that returns model (mean, var, std) + prior + By default uniform distribution within model bounds. + n_inits : int, optional + Number of initialization points in internal optimization. + max_opt_iters : int, optional + Max iterations to optimize when finding the next point. + noise_var : float or np.array, optional + Acquisition noise variance for adding noise to the points near the optimized + location. If array, must be 1d specifying the variance for different dimensions. + Default: no added noise. + exploration_rate : float, optional + Exploration rate of the acquisition function (if supported) + seed : int, optional + Seed for getting consistent acquisition results. Used in getting random + starting locations in acquisition function optimization. + """ - # check if covariance is diagonal - self._diagonal_cov = np.all(np.diag(np.diag(self.noise_cov)) == self.noise_cov) - if self._diagonal_cov: - self._noise_sigma = np.sqrt(np.diag(self.noise_cov)) + self.model = model + self.prior = prior + self.n_inits = int(n_inits) + self.max_opt_iters = int(max_opt_iters) - self.random_state = np.random.RandomState(seed) + if noise_var is not None and np.asanyarray(noise_var).ndim > 1: + raise ValueError("Noise variance must be a float or 1d vector of variances " + "for the different input dimensions.") + self.noise_var = noise_var + self.exploration_rate = exploration_rate + self.random_state = np.random if seed is None else np.random.RandomState(seed) def evaluate(self, x, t=None): """Evaluates the acquisition function value at 'x'. @@ -75,61 +78,69 @@ def evaluate_gradient(self, x, t=None): """ raise NotImplementedError - def acquire(self, n_values, pending_locations=None, t=None): + def acquire(self, n, t=None): """Returns the next batch of acquisition points. - - Gaussian noise ~N(0, self.noise_cov) is added to the acquired points. + + Gaussian noise ~N(0, self.noise_var) is added to the acquired points. Parameters ---------- - n_values : int - Number of values to return. - pending_locations : None or numpy 2d array - If given, acquisition functions may - use the locations in choosing the next sampling - location. Locations should be in rows. + n : int + Number of acquisition points to return. t : int - Current iteration (starting from 0). + Current acq_batch_index (starting from 0). + random_state : np.random.RandomState, optional Returns ------- - locations : 2D np.ndarray of shape (n_values, ...) + x : np.ndarray + The shape is (n_values, input_dim) """ - logger.debug('Acquiring {} values'.format(n_values)) + logger.debug('Acquiring the next batch of {} values'.format(n)) + # Optimize the current minimum obj = lambda x: self.evaluate(x, t) grad_obj = lambda x: self.evaluate_gradient(x, t) - minloc, minval = minimize(obj, self.model.bounds, grad_obj, self.prior, self.n_inits, self.max_opt_iters) - x = np.tile(minloc, (n_values, 1)) - - # add some noise for more efficient exploration - if self._diagonal_cov: - for ii in range(self.model.input_dim): - if self._noise_sigma[ii] > 0: - bounds_a = (self.model.bounds[ii][0] - x[:, ii]) / self._noise_sigma[ii] - bounds_b = (self.model.bounds[ii][1] - x[:, ii]) / self._noise_sigma[ii] - x[:, ii] = truncnorm.rvs(bounds_a, bounds_b, loc=x[:, ii], scale=self._noise_sigma[ii], - size=n_values, random_state=self.random_state) - - else: - x += multivariate_normal.rvs(cov=self.noise_cov, size=n_values, random_state=self.random_state) \ - .reshape((n_values, -1)) - - # make sure the acquired points stay within bounds simply by clipping - for ii in range(self.model.input_dim): - x[:, ii] = np.clip(x[:, ii], *self.model.bounds[ii]) + xhat, _ = minimize(obj, self.model.bounds, grad_obj, self.prior, self.n_inits, + self.max_opt_iters, random_state=self.random_state) + + # Create n copies of the minimum + x = np.tile(xhat, (n, 1)) + # Add noise for more efficient fitting of GP + x = self._add_noise(x) return x + def _add_noise(self, x): + # Add noise for more efficient fitting of GP + if self.noise_var is not None: + noise_var = np.asanyarray(self.noise_var) + if noise_var.ndim == 0: + noise_var = np.tile(noise_var, self.model.input_dim) + + for i in range(self.model.input_dim): + std = np.sqrt(noise_var[i]) + if std == 0: + continue + xi = x[:, i] + a = (self.model.bounds[i][0] - xi) / std + b = (self.model.bounds[i][1] - xi) / std + x[:, i] = truncnorm.rvs(a, b, loc=xi, scale=std, size=len(x), + random_state=self.random_state) + + return x class LCBSC(AcquisitionBase): """Lower Confidence Bound Selection Criterion. Srinivas et al. call it GP-LCB. - - Parameter delta must be in (0, 1). The theoretical upper bound for total regret in - Srinivas et al. has a probability greater or equal to 1 - delta, so values of delta - very close to 1 do not make much sense in that respect. - + + LCBSC uses the parameter delta which is here equivalent to 1/exploration_rate. + + Parameter delta should be in (0, 1) for the theoretical results to hold. The + theoretical upper bound for total regret in Srinivas et al. has a probability greater + or equal to 1 - delta, so values of delta very close to 1 or over it do not make much + sense in that respect. + Delta is roughly the exploitation tendency of the acquisition function. References @@ -149,11 +160,27 @@ class LCBSC(AcquisitionBase): would be t**(2d + 2). """ - def __init__(self, *args, delta=0.1, **kwargs): + def __init__(self, *args, delta=None, **kwargs): + """ + + Parameters + ---------- + args + delta : float, optional + In between (0, 1). Default is 1/exploration_rate. If given, overrides the + exploration_rate. + kwargs + """ + if delta is not None: + if delta <= 0 or delta >= 1: + logger.warning('Parameter delta should be in the interval (0,1)') + kwargs['exploration_rate'] = 1/delta + super(LCBSC, self).__init__(*args, **kwargs) - if delta <= 0 or delta >= 1: - raise ValueError('Parameter delta must be in the interval (0,1)') - self.delta = delta + + @property + def delta(self): + return 1/self.exploration_rate def _beta(self, t): # Start from 0 @@ -161,7 +188,7 @@ def _beta(self, t): d = self.model.input_dim return 2*np.log(t**(2*d + 2) * np.pi**2 / (3*self.delta)) - def evaluate(self, x, t): + def evaluate(self, x, t=None): """Lower confidence bound selection criterion: mean - sqrt(\beta_t) * std @@ -175,7 +202,7 @@ def evaluate(self, x, t): mean, var = self.model.predict(x, noiseless=True) return mean - np.sqrt(self._beta(t) * var) - def evaluate_gradient(self, x, t): + def evaluate_gradient(self, x, t=None): """Gradient of the lower confidence bound selection criterion. Parameters @@ -192,7 +219,7 @@ def evaluate_gradient(self, x, t): class UniformAcquisition(AcquisitionBase): - def acquire(self, n_values, pending_locations=None, t=None): + def acquire(self, n, t=None): bounds = np.stack(self.model.bounds) return uniform(bounds[:,0], bounds[:,1] - bounds[:,0])\ - .rvs(size=(n_values, self.model.input_dim), random_state=self.random_state) + .rvs(size=(n, self.model.input_dim), random_state=self.random_state) diff --git a/elfi/methods/bo/gpy_regression.py b/elfi/methods/bo/gpy_regression.py index 1e42bae8..c94b0740 100644 --- a/elfi/methods/bo/gpy_regression.py +++ b/elfi/methods/bo/gpy_regression.py @@ -84,33 +84,6 @@ def __str__(self): def __repr__(self): return self.__str__() - def _check_input(self, x): - """Check and interpret input locations. - - Parameters - ---------- - x : np.array - numpy compatible array of points - if len(x.shape) == 1 will be cast to 2D with x[None, :] - - Returns - ------- - 2D numpy array with shape (n_locations, input_dim) - or raises a ValueError - """ - if type(x) is not np.ndarray: - try: - x = np.array(x) - except: - raise ValueError("Location must be of type interpretable as a numpy array (was {})".format(type(x))) - if len(x.shape) == 1: - # add missing dimension - x = x[None, :] - if x.shape[1] != self.input_dim: - raise ValueError("Location dimensions ({}) must match model dimensions ({})"\ - .format(x.shape[1], self.input_dim)) - return x - def predict(self, x, noiseless=False): """Returns the GP model mean and variance at x. @@ -131,7 +104,10 @@ def predict(self, x, noiseless=False): var : np.array with shape (x.shape[0], 1) """ - x = self._check_input(x) + + # Ensure it's 2d for GPy + x = np.asanyarray(x).reshape((-1, self.input_dim)) + if self._gp is None: # TODO: return from GP mean function if given return np.zeros((x.shape[0], 1)), \ @@ -195,7 +171,10 @@ def predictive_gradients(self, x): grad_var : np.array with shape (x.shape[0], input_dim) """ - x = self._check_input(x) + + # Ensure it's 2d for GPy + x = np.asanyarray(x).reshape((-1, self.input_dim)) + if self._gp is None: # TODO: return from GP mean function if given return np.zeros((x.shape[0], self.input_dim)), \ @@ -283,9 +262,10 @@ def update(self, x, y, optimize=False): # Reconstruct with new data x = np.r_[self._gp.X, x] y = np.r_[self._gp.Y, y] - kernel = self._gp.kern + # It seems that GPy will do some optimization unless you make copies of everything + kernel = self._gp.kern.copy() if self._gp.kern else None noise_var = self._gp.Gaussian_noise.variance[0] - mean_function = self._gp.mean_function + mean_function = self._gp.mean_function.copy() if self._gp.mean_function else None self._gp = self._make_gpy_instance(x, y, kernel=kernel, noise_var=noise_var, mean_function=mean_function) @@ -309,6 +289,16 @@ def n_evidence(self): return 0 return self._gp.num_data + @property + def X(self): + """Return input evidence""" + return self._gp.X + + @property + def Y(self): + """Return output evidence""" + return self._gp.Y + def copy(self): kopy = copy.copy(self) if self._gp: @@ -321,3 +311,6 @@ def copy(self): kopy.gp_params['mean_function'] = self.gp_params['mean_function'].copy() return kopy + + def __copy__(self): + return self.copy() diff --git a/elfi/methods/bo/utils.py b/elfi/methods/bo/utils.py index 3fc262e7..1237f66e 100644 --- a/elfi/methods/bo/utils.py +++ b/elfi/methods/bo/utils.py @@ -38,14 +38,14 @@ def minimize(fun, bounds, grad=None, prior=None, n_start_points=10, maxiter=1000 ndim = len(bounds) start_points = np.empty((n_start_points, ndim)) - # TODO: use same prior as the bo.acquisition.UniformAcquisition if prior is None: # Sample initial points uniformly within bounds - random_state = random_state or np.random.RandomState() + # TODO: combine with the the bo.acquisition.UniformAcquisition method? + random_state = random_state or np.random for i in range(ndim): start_points[:, i] = random_state.uniform(*bounds[i], n_start_points) else: - start_points = prior.rvs(n_start_points) + start_points = prior.rvs(n_start_points, random_state=random_state) if len(start_points.shape) == 1: # Add possibly missing dimension when ndim=1 start_points = start_points[:, None] diff --git a/elfi/methods/parameter_inference.py b/elfi/methods/parameter_inference.py index 690c06fd..211e879d 100644 --- a/elfi/methods/parameter_inference.py +++ b/elfi/methods/parameter_inference.py @@ -6,20 +6,20 @@ import numpy as np import elfi.client -import elfi.visualization.visualization as vis -import elfi.visualization.interactive as visin import elfi.methods.mcmc as mcmc import elfi.model.augmenter as augmenter - -from elfi.utils import is_array +import elfi.visualization.interactive as visin +import elfi.visualization.visualization as vis from elfi.loader import get_sub_seed from elfi.methods.bo.acquisition import LCBSC from elfi.methods.bo.gpy_regression import GPyRegression from elfi.methods.bo.utils import stochastic_optimization from elfi.methods.posteriors import BolfiPosterior from elfi.methods.results import Sample, SmcSample, BolfiSample, OptimizationResult -from elfi.methods.utils import GMDistribution, weighted_var, ModelPrior +from elfi.methods.utils import GMDistribution, weighted_var, ModelPrior, batch_to_arr2d, \ + arr2d_to_batch, ceil_to_batch_size from elfi.model.elfi_model import ComputationContext, NodeReference, ElfiModel +from elfi.utils import is_array logger = logging.getLogger(__name__) @@ -204,20 +204,6 @@ def prepare_new_batch(self, batch_index): """ pass - def _init_model(self, model): - """Initialize the model. - - If your algorithm needs to modify the model, you may do so here. ELFI will call - this method before compiling the model. - - Parameters - ---------- - model : elfi.ElfiModel - A copy of the original model. - - """ - return model - def plot_state(self, **kwargs): """Plot the current state of the algorithm. @@ -288,12 +274,11 @@ def iterate(self): """ # Submit new batches if allowed - while self._allow_submit: - batch_index = self.batches.next_index - batch = self.prepare_new_batch(batch_index) - self.batches.submit(batch) + while self._allow_submit(self.batches.next_index): + next_batch = self.prepare_new_batch(self.batches.next_index) + self.batches.submit(next_batch) - # Handle the next batch in succession + # Handle the next ready batch in succession batch, batch_index = self.batches.wait_next() self.update(batch, batch_index) @@ -301,8 +286,7 @@ def iterate(self): def finished(self): return self._objective_n_batches <= self.state['n_batches'] - @property - def _allow_submit(self): + def _allow_submit(self, batch_index): return self.max_parallel_batches > self.batches.num_pending and \ self._has_batches_to_submit and \ (not self.batches.has_ready) @@ -323,35 +307,6 @@ def _objective_n_batches(self): raise ValueError('Objective must define either `n_batches` or `n_sim`.') return n_batches - def _to_array(self, batches, outputs=None): - """Helper method to turn batches into numpy array - - Parameters - ---------- - batches : list or dict - A list of batches or as single batch - outputs : list, optional - Name of outputs to include in the array. Default is the `self.outputs` - - Returns - ------- - np.array - 2d, where columns are batch outputs - - """ - - if not batches: - return [] - if not isinstance(batches, list): - batches = [batches] - outputs = outputs or self.output_names - - rows = [] - for batch_ in batches: - rows.append(np.column_stack([batch_[output] for output in outputs])) - - return np.vstack(rows) - def _extract_result_kwargs(self): """Extract common arguments for the ParameterInferenceResult object from the inference instance. @@ -512,7 +467,7 @@ def update(self, batch, batch_index): self._init_samples_lazy(batch) self._merge_batch(batch) self._update_state_meta() - self._update_objective() + self._update_objective_n_batches() def extract_result(self): """Extracts the result from the current state @@ -582,8 +537,8 @@ def _update_state_meta(self): s['threshold'] = s['samples'][self.discrepancy_name][o['n_samples'] - 1].item() s['accept_rate'] = min(1, o['n_samples']/s['n_sim']) - def _update_objective(self): - """Updates the objective n_batches if applicable""" + def _update_objective_n_batches(self): + # Only in the case that the threshold is used if not self.objective.get('threshold'): return s = self.state @@ -591,14 +546,19 @@ def _update_objective(self): # noinspection PyTypeChecker n_acceptable = np.sum(s['samples'][self.discrepancy_name] <= t) if s['samples'] else 0 - if n_acceptable == 0: return - - accept_rate_t = n_acceptable / s['n_sim'] - # Add some margin to estimated batches_total. One could use confidence bounds here - margin = .2 * self.batch_size * int(n_acceptable < n_samples) - n_batches = (n_samples / accept_rate_t + margin) / self.batch_size - - self.objective['n_batches'] = ceil(n_batches) + if n_acceptable == 0: + # No acceptable samples found yet, increase n_batches of objective by one in + # order to keep simulating + n_batches = self.objective['n_batches'] + 1 + else: + accept_rate_t = n_acceptable / s['n_sim'] + # Add some margin to estimated n_batches. One could also use confidence + # bounds here + margin = .2 * self.batch_size * int(n_acceptable < n_samples) + n_batches = (n_samples / accept_rate_t + margin) / self.batch_size + n_batches = ceil(n_batches) + + self.objective['n_batches'] = n_batches logger.debug('Estimated objective n_batches=%d' % self.objective['n_batches']) def plot_state(self, **options): @@ -619,29 +579,31 @@ def __init__(self, model, discrepancy_name=None, output_names=None, **kwargs): # Add the prior pdf nodes to the model model = model.copy() - pdf_name = augmenter.add_pdf_nodes(model)[0] + logpdf_name = augmenter.add_pdf_nodes(model, log=True)[0] - output_names = [discrepancy_name] + model.parameter_names + [pdf_name] + \ + output_names = [discrepancy_name] + model.parameter_names + [logpdf_name] + \ (output_names or []) super(SMC, self).__init__(model, output_names, **kwargs) self.discrepancy_name = discrepancy_name - self.prior_pdf = pdf_name + self.prior_logpdf = logpdf_name self.state['round'] = 0 self._populations = [] self._rejection = None + self._round_random_state = None def set_objective(self, n_samples, thresholds): self.objective.update(dict(n_samples=n_samples, n_batches=self.max_parallel_batches, round=len(thresholds) - 1, thresholds=thresholds)) - self._new_round() + self._init_new_round() def extract_result(self): pop = self._extract_population() - return SmcSample(outputs=pop.outputs, populations=self._populations.copy() + [pop], + return SmcSample(outputs=pop.outputs, + populations=self._populations.copy() + [pop], **self._extract_result_kwargs()) def update(self, batch, batch_index): @@ -652,31 +614,38 @@ def update(self, batch, batch_index): if self.state['round'] < self.objective['round']: self._populations.append(self._extract_population()) self.state['round'] += 1 - self._new_round() + self._init_new_round() self._update_state() self._update_objective() def prepare_new_batch(self, batch_index): - # Use the actual prior if self.state['round'] == 0: + # Use the actual prior return # Sample from the proposal - params = GMDistribution.rvs(*self._gm_params, size=self.batch_size) - # TODO: support vector parameter nodes - batch = {p:params[:,i] for i, p in enumerate(self.parameter_names)} + params = GMDistribution.rvs(*self._gm_params, size=self.batch_size, + random_state=self._round_random_state) + + batch = arr2d_to_batch(params, self.parameter_names) return batch - def _new_round(self): + def _init_new_round(self): + round = self.state['round'] + dashes = '-'*16 - logger.info('%s Starting round %d %s' % (dashes, self.state['round'], dashes)) + logger.info('%s Starting round %d %s' % (dashes, round, dashes)) + + # Get a subseed for this round for ensuring consistent results for the round + seed = self.seed if round == 0 else get_sub_seed(self.seed, round) + self._round_random_state = np.random.RandomState(seed) self._rejection = Rejection(self.model, discrepancy_name=self.discrepancy_name, output_names=self.output_names, batch_size=self.batch_size, - seed=self.seed, + seed=seed, max_parallel_batches=self.max_parallel_batches) self._rejection.set_objective(self.objective['n_samples'], @@ -695,13 +664,25 @@ def _compute_weights_and_cov(self, pop): params = np.column_stack(tuple([pop.outputs[p] for p in self.parameter_names])) if self._populations: - q_densities = GMDistribution.pdf(params, *self._gm_params) - w = pop.outputs[self.prior_pdf] / q_densities + q_logpdf = GMDistribution.logpdf(params, *self._gm_params) + w = np.exp(pop.outputs[self.prior_logpdf] - q_logpdf) else: w = np.ones(pop.n_samples) + if np.count_nonzero(w) == 0: + raise RuntimeError("All sample weights are zero. If you are using a prior " + "with a bounded support, this may be caused by specifying " + "a too small sample size.") + # New covariance cov = 2 * np.diag(weighted_var(params, w)) + + if not np.all(np.isfinite(cov)): + logger.warning("Could not estimate the sample covariance. This is often " + "caused by majority of the sample weights becoming zero." + "Falling back to using unit covariance.") + cov = np.diag(np.ones(params.shape[1])) + return w, cov def _update_state(self): @@ -733,32 +714,39 @@ def current_population_threshold(self): class BayesianOptimization(ParameterInference): """Bayesian Optimization of an unknown target function.""" - def __init__(self, model, target_name=None, batch_size=1, initial_evidence=None, - update_interval=10, bounds=None, target_model=None, - acquisition_method=None, acq_noise_cov=0, **kwargs): + def __init__(self, model, target_name=None, bounds=None, initial_evidence=None, + update_interval=10, target_model=None, acquisition_method=None, + acq_noise_var=0, exploration_rate=10, batch_size=1, + batches_per_acquisition=None, **kwargs): """ Parameters ---------- model : ElfiModel or NodeReference target_name : str or NodeReference Only needed if model is an ElfiModel - target_model : GPyRegression, optional - acquisition_method : Acquisition, optional - Method of acquiring evidence points. Defaults to LCBSC. - acq_noise_cov : float or np.array, optional - Covariance of the noise added in the default LCBSC acquisition method. - If an array, should have the shape (n_params,) or (n_params, n_params). bounds : dict The region where to estimate the posterior for each parameter in - model.parameters. - `{'parameter_name':(lower, upper), ... }` + model.parameters: dict('parameter_name':(lower, upper), ... )`. Not used if + custom target_model is given. initial_evidence : int, dict, optional Number of initial evidence or a precomputed batch dict containing parameter - and discrepancy values. Defaults to max(10, 2**model_input_dim + 1). + and discrepancy values. Default value depends on the dimensionality. update_interval : int How often to update the GP hyperparameters of the target_model - exploration_rate : float + target_model : GPyRegression, optional + acquisition_method : Acquisition, optional + Method of acquiring evidence points. Defaults to LCBSC. + acq_noise_var : float or np.array, optional + Variance(s) of the noise added in the default LCBSC acquisition method. + If an array, should be 1d specifying the variance for each dimension. + exploration_rate : float, optional Exploration rate of the acquisition method + batch_size : int, optional + Elfi batch size. Defaults to 1. + batches_per_acquisition : int, optional + How many batches will be acquired at the same time. Defaults to + max_parallel_batches. + **kwargs """ model, target_name = self._resolve_model(model, target_name) @@ -766,119 +754,167 @@ def __init__(self, model, target_name=None, batch_size=1, initial_evidence=None, super(BayesianOptimization, self).__init__(model, output_names, batch_size=batch_size, **kwargs) + target_model = target_model or \ + GPyRegression(self.model.parameter_names, bounds=bounds) + self.target_name = target_name - target_model = \ - target_model or GPyRegression(self.model.parameter_names, bounds=bounds) + self.target_model = target_model + + n_precomputed = 0 + n_initial, precomputed = self._resolve_initial_evidence(initial_evidence) + if precomputed is not None: + params = batch_to_arr2d(precomputed, self.parameter_names) + n_precomputed = len(params) + self.target_model.update(params, precomputed[target_name]) + + self.batches_per_acquisition = batches_per_acquisition or self.max_parallel_batches + self.acquisition_method = acquisition_method or \ + LCBSC(self.target_model, + prior=ModelPrior(self.model), + noise_var=acq_noise_var, + exploration_rate=exploration_rate, + seed=self.seed) + + self.n_initial_evidence = n_initial + self.n_precomputed_evidence = n_precomputed + self.update_interval = update_interval - # Fix bounds of user-supplied target_model - if type(target_model.bounds) == dict: - target_model.bounds = [target_model.bounds[k] for k in model.parameter_names] + self.state['n_evidence'] = self.n_precomputed_evidence + self.state['last_GP_update'] = self.n_initial_evidence + self.state['acquisition'] = [] + def _resolve_initial_evidence(self, initial_evidence): # Some sensibility limit for starting GP regression - n_initial_required = max(10, 2**target_model.input_dim + 1) - self._n_precomputed = 0 + precomputed = None + n_required = max(10, 2**self.target_model.input_dim + 1) + n_required = ceil_to_batch_size(n_required, self.batch_size) if initial_evidence is None: - initial_evidence = n_initial_required - elif not isinstance(initial_evidence, int): - # Add precomputed batch data - params = self._to_array(initial_evidence, self.parameter_names) - target_model.update(params, initial_evidence[self.target_name]) - initial_evidence = len(params) - self._n_precomputed = initial_evidence - - if initial_evidence < 0: - raise ValueError('Number of initial evidence must be positive or zero (was {})'.format(initial_evidence)) - if initial_evidence < n_initial_required: - logger.warning('BOLFI should have at least {} initialization points for reliable initialization (now {})'\ - .format(n_initial_required, initial_evidence)) - - if initial_evidence % self.batch_size != 0: - raise ValueError('Number of initial evidence must be divisible by the batch size') - - # TODO: check the case when there is no prior in the model - self.acquisition_method = acquisition_method or \ - LCBSC(target_model, prior=ModelPrior(self.model), - noise_cov=acq_noise_cov, seed=self.seed) - # TODO: move some of these to objective - self.n_evidence = initial_evidence - self.target_model = target_model - self.n_initial_evidence = initial_evidence - self.update_interval = update_interval + n_initial_evidence = n_required + elif isinstance(initial_evidence, (int, np.int, float)): + n_initial_evidence = int(initial_evidence) + else: + precomputed = initial_evidence + n_initial_evidence = len(precomputed[self.target_name]) + + if n_initial_evidence < 0: + raise ValueError('Number of initial evidence must be positive or zero ' + '(was {})'.format(initial_evidence)) + elif n_initial_evidence < n_required: + logger.warning('We recommend having at least {} initialization points for ' + 'the initialization (now {})'\ + .format(n_required, n_initial_evidence)) - def set_objective(self, n_evidence): - """You can continue BO by giving a larger n_evidence""" - self.state['pending'] = OrderedDict() - self.state['last_update'] = self.state.get('last_update') or self._n_precomputed + if precomputed is None and (n_initial_evidence % self.batch_size != 0): + logger.warning('Number of initial_evidence %d is not divisible by ' + 'batch_size %d. Rounding it up...' % + (n_initial_evidence, self.batch_size)) + n_initial_evidence = ceil_to_batch_size(n_initial_evidence, self.batch_size) - if n_evidence and self.n_evidence > n_evidence: - raise ValueError('New n_evidence must be greater than the earlier') + return n_initial_evidence, precomputed - self.n_evidence = n_evidence or self.n_evidence - self.objective['n_batches'] = ceil((self.n_evidence - self._n_precomputed) / self.batch_size) + @property + def n_evidence(self): + return self.state.get('n_evidence', 0) + + @property + def acq_batch_size(self): + return self.batch_size*self.batches_per_acquisition + + def set_objective(self, n_evidence=None): + """You can continue BO by giving a larger n_evidence + + Parameters + ---------- + n_evidence : int + Number of total evidence for the GP fitting. This includes any initial + evidence. + + """ + if n_evidence is None: + n_evidence = self.objective.get('n_evidence', self.n_evidence) + + if n_evidence < self.n_evidence: + logger.warning('Requesting less evidence than there already exists') + + self.objective['n_evidence'] = n_evidence + self.objective['n_sim'] = n_evidence - self.n_precomputed_evidence def extract_result(self): - param, min_value = stochastic_optimization(self.target_model.predict_mean, - self.target_model.bounds) + x_min, _ = stochastic_optimization(self.target_model.predict_mean, + self.target_model.bounds, + seed=self.seed) - param_hat = {} - for i, p in enumerate(self.model.parameter_names): - # Preserve as array - param_hat[p] = param[i] + batch_min = arr2d_to_batch(x_min, self.parameter_names) + outputs = arr2d_to_batch(self.target_model.X, self.parameter_names) + outputs[self.target_name] = self.target_model.Y - # TODO: add evidence to outputs - return OptimizationResult(x=param_hat, - outputs=[], + return OptimizationResult(x_min=batch_min, + outputs=outputs, **self._extract_result_kwargs()) def update(self, batch, batch_index): """Update the GP regression model of the target node. """ - self.state['pending'].pop(batch_index, None) + super(BayesianOptimization, self).update(batch, batch_index) + self.state['n_evidence'] += self.batch_size - params = self._to_array(batch, self.parameter_names) + params = batch_to_arr2d(batch, self.parameter_names) self._report_batch(batch_index, params, batch[self.target_name]) optimize = self._should_optimize() self.target_model.update(params, batch[self.target_name], optimize) - if optimize: - self.state['last_update'] = self.target_model.n_evidence - - self.state['n_batches'] += 1 - self.state['n_sim'] += self.batch_size + self.state['last_GP_update'] = self.target_model.n_evidence def prepare_new_batch(self, batch_index): - if self._n_submitted_evidence < self.n_initial_evidence - self._n_precomputed: - return + t = self._get_acquisition_index(batch_index) - pending_params = self._to_array(list(self.state['pending'].values()), - self.parameter_names) - t = self.batches.total - int(self.n_initial_evidence / self.batch_size) - new_param = self.acquisition_method.acquire(self.batch_size, pending_params, t) + # Check if we still should take initial points from the prior + if t < 0: return - # TODO: implement self._to_batch method? - batch = {p: new_param[:,i] for i, p in enumerate(self.parameter_names)} - self.state['pending'][batch_index] = batch + # Take the next batch from the acquisition_batch + acquisition = self.state['acquisition'] + if len(acquisition) == 0: + acquisition = self.acquisition_method.acquire(self.acq_batch_size, t=t) + + batch = arr2d_to_batch(acquisition[:self.batch_size], self.parameter_names) + self.state['acquisition'] = acquisition[self.batch_size:] return batch + def _get_acquisition_index(self, batch_index): + acq_batch_size = self.batch_size * self.batches_per_acquisition + initial_offset = self.n_initial_evidence - self.n_precomputed_evidence + starting_sim_index = self.batch_size * batch_index + + t = (starting_sim_index - initial_offset) // acq_batch_size + return t + # TODO: use state dict @property def _n_submitted_evidence(self): return self.batches.total * self.batch_size - @property - def _allow_submit(self): - # TODO: replace this by handling the objective['n_batches'] - # Do not start acquisitions unless all of the initial evidence is ready - prevent = self.target_model.n_evidence < self.n_initial_evidence <= \ - self._n_submitted_evidence + self._n_precomputed - return not prevent and super(BayesianOptimization, self)._allow_submit + def _allow_submit(self, batch_index): + if not super(BayesianOptimization, self)._allow_submit(batch_index): + return False + + t = self._get_acquisition_index(batch_index) + if t < 0: + return True + + # Do not allow acquisition until previous acquisitions are ready (as well + # as all initial acquisitions) + acquisitions_left = len(self.state['acquisition']) + if acquisitions_left == 0 and self.batches.has_pending: + return False + + return True def _should_optimize(self): current = self.target_model.n_evidence + self.batch_size - next_update = self.state['last_update'] + self.update_interval + next_update = self.state['last_GP_update'] + self.update_interval return current >= self.n_initial_evidence and current >= next_update def _report_batch(self, batch_index, params, distances): @@ -905,13 +941,13 @@ def plot_state(self, **options): gp.bounds, self.parameter_names, title='GP target surface', - points = gp._gp.X, + points = gp.X, axes=f.axes[0], **options) # Draw the latest acquisitions if options.get('interactive'): - point = gp._gp.X[-1, :] - if len(gp._gp.X) > 1: + point = gp.X[-1, :] + if len(gp.X) > 1: f.axes[1].scatter(*point, color='red') displays = [gp._gp] @@ -920,12 +956,12 @@ def plot_state(self, **options): from IPython import display displays.insert(0, display.HTML( 'Iteration {}: Acquired {} at {}'.format( - len(gp._gp.Y), gp._gp.Y[-1][0], point))) + len(gp.Y), gp.Y[-1][0], point))) # Update visin._update_interactive(displays, options) - acq = lambda x : self.acquisition_method.evaluate(x, len(gp._gp.X)) + acq = lambda x : self.acquisition_method.evaluate(x, len(gp.X)) # Draw the acquisition surface visin.draw_contour(acq, gp.bounds, @@ -1054,9 +1090,8 @@ def sample(self, n_samples, warmup=None, n_chains=4, threshold=None, initials=No if np.asarray(initials).shape != (n_chains, self.target_model.input_dim): raise ValueError("The shape of initials must be (n_chains, n_params).") else: - # TODO: now GPy specific - inds = np.argsort(self.target_model._gp.Y[:,0]) - initials = np.asarray(self.target_model._gp.X[inds]) + inds = np.argsort(self.target_model.Y[:,0]) + initials = np.asarray(self.target_model.X[inds]) self.target_model.is_sampling = True # enables caching for default RBF kernel diff --git a/elfi/methods/results.py b/elfi/methods/results.py index ca4d9c52..1869d3d8 100644 --- a/elfi/methods/results.py +++ b/elfi/methods/results.py @@ -4,7 +4,6 @@ from collections import OrderedDict import numpy as np -import scipy as sp from matplotlib import pyplot as plt import elfi.visualization.visualization as vis @@ -35,19 +34,19 @@ def __init__(self, method_name, outputs, parameter_names, **kwargs): class OptimizationResult(ParameterInferenceResult): - def __init__(self, x, **kwargs): + def __init__(self, x_min, **kwargs): """ Parameters ---------- - x + x_min The optimized parameters **kwargs See `ParameterInferenceResult` """ super(OptimizationResult, self).__init__(**kwargs) - self.x = x + self.x_min = x_min # TODO: refactor diff --git a/elfi/methods/utils.py b/elfi/methods/utils.py index 2444bf04..b1516d29 100644 --- a/elfi/methods/utils.py +++ b/elfi/methods/utils.py @@ -1,5 +1,5 @@ import logging -import warnings +from math import ceil import numpy as np import scipy.stats as ss @@ -12,6 +12,66 @@ logger = logging.getLogger(__name__) +def arr2d_to_batch(x, names): + """Convert 2d array to batch dictionary columnwise + + Parameters + ---------- + x : np.ndarray + 2d array of values + names : list[str] + List of names + + Returns + ------- + dict + A batch dictionary + + """ + # TODO: support vector parameter nodes + try: + x = x.reshape((-1, len(names))) + except: + raise ValueError("A dimension mismatch in converting array to batch dictionary. " + "This may be caused by multidimensional " + "prior nodes that are not yet supported.") + batch = {p:x[:,i] for i, p in enumerate(names)} + return batch + + +def batch_to_arr2d(batches, names): + """Helper method to turn batches into numpy array + + Parameters + ---------- + batches : dict or list + A list of batches or a single batch + names : list + Name of outputs to include in the array. Specifies the order. + + Returns + ------- + np.array + 2d, where columns are batch outputs + + """ + + if not batches: + return [] + if not isinstance(batches, list): + batches = [batches] + + rows = [] + for batch_ in batches: + rows.append(np.column_stack([batch_[n] for n in names])) + + return np.vstack(rows) + + +def ceil_to_batch_size(num, batch_size): + return int(batch_size * ceil(num/batch_size)) + + def normalize_weights(weights): w = np.atleast_1d(weights) if np.any(w < 0): @@ -72,7 +132,7 @@ def pdf(cls, x, means, cov=1, weights=None): the mean of the first gaussian component. weights : array_like 1d array of weights of the gaussian mixture components - cov : array_like + cov : array_like, float a shared covariance matrix for the mixture components """ @@ -94,6 +154,9 @@ def pdf(cls, x, means, cov=1, weights=None): else: return d + @classmethod + def logpdf(cls, x, means, cov=1, weights=None): + return np.log(cls.pdf(x, means=means, cov=cov, weights=weights)) @classmethod def rvs(cls, means, cov=1, weights=None, size=1, random_state=None): diff --git a/elfi/utils.py b/elfi/utils.py index d3c86c1c..c2c621c1 100644 --- a/elfi/utils.py +++ b/elfi/utils.py @@ -1,4 +1,5 @@ import scipy.stats as ss +import numpy as np import networkx as nx @@ -48,7 +49,7 @@ def get_sub_seed(random_state, sub_seed_index, high=2**31): Parameters ---------- - random_state : np.random.RandomState + random_state : np.random.RandomState, int sub_seed_index : int high : int upper limit for the range of sub seeds (exclusive) @@ -66,6 +67,9 @@ def get_sub_seed(random_state, sub_seed_index, high=2**31): """ + if isinstance(random_state, (int, np.integer)): + random_state = np.random.RandomState(random_state) + if sub_seed_index >= high: raise ValueError("Sub seed index {} is out of range".format(sub_seed_index)) diff --git a/tests/conftest.py b/tests/conftest.py index 2e0cf595..8d111f24 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -64,6 +64,7 @@ def with_all_clients(client): def use_logging(): logging.basicConfig(level=logging.DEBUG) logging.getLogger('elfi.executor').setLevel(logging.WARNING) + logging.getLogger('elfi.compiler').setLevel(logging.WARNING) @pytest.fixture() diff --git a/tests/functional/test_consistency.py b/tests/functional/test_consistency.py new file mode 100644 index 00000000..5e6b39b4 --- /dev/null +++ b/tests/functional/test_consistency.py @@ -0,0 +1,118 @@ +import logging + +import numpy as np +import pytest + +import elfi + +"""This module tests the consistency of results when using the same seed.""" + + +def check_consistent_sample(sample, sample_diff, sample_same): + assert not np.array_equal(sample.outputs['t1'], sample_diff.outputs['t1']) + + assert np.array_equal(sample.outputs['t1'], sample_same.outputs['t1']) + assert np.array_equal(sample.outputs['t2'], sample_same.outputs['t2']) + + # BOLFI does not have d in its outputs + if 'd' in sample.outputs: + assert np.array_equal(sample.outputs['d'], sample_same.outputs['d']) + + +@pytest.mark.usefixtures('with_all_clients') +def test_rejection(ma2): + bs = 3 + n_samples = 3 + n_sim = 9 + + rej = elfi.Rejection(ma2, 'd', batch_size=bs) + sample = rej.sample(n_samples, n_sim=n_sim) + seed = rej.seed + + rej = elfi.Rejection(ma2, 'd', batch_size=bs) + sample_diff = rej.sample(n_samples, n_sim=n_sim) + + rej = elfi.Rejection(ma2, 'd', batch_size=bs, seed=seed) + sample_same = rej.sample(n_samples, n_sim=n_sim) + + check_consistent_sample(sample, sample_diff, sample_same) + + +@pytest.mark.usefixtures('with_all_clients') +def test_smc(ma2): + bs = 3 + n_samples = 10 + thresholds = [1, .9, .8] + + smc = elfi.SMC(ma2, 'd', batch_size=bs) + sample = smc.sample(n_samples, thresholds=thresholds) + seed = smc.seed + + smc = elfi.SMC(ma2, 'd', batch_size=bs, seed=seed) + sample_same = smc.sample(n_samples, thresholds=thresholds) + + smc = elfi.SMC(ma2, 'd', batch_size=bs) + sample_diff = smc.sample(n_samples, thresholds=thresholds) + + check_consistent_sample(sample, sample_diff, sample_same) + + +@pytest.mark.usefixtures('with_all_clients') +def test_bo(ma2): + bs = 2 + upd_int = 1 + n_evi = 16 + init_evi = 10 + bounds = {'t1':(-2,2), 't2':(-1, 1)} + anv = .1 + + bo = elfi.BayesianOptimization(ma2, 'd', initial_evidence=init_evi, + update_interval=upd_int, batch_size=bs, + bounds=bounds, acq_noise_var=anv) + res = bo.infer(n_evidence=n_evi) + seed = bo.seed + + + bo = elfi.BayesianOptimization(ma2, 'd', seed=seed, initial_evidence=init_evi, + update_interval=upd_int, batch_size=bs, + bounds=bounds, acq_noise_var=anv) + res_same = bo.infer(n_evidence=n_evi) + + + bo = elfi.BayesianOptimization(ma2, 'd', initial_evidence=init_evi, + update_interval=upd_int, batch_size=bs, + bounds=bounds, acq_noise_var=anv) + res_diff = bo.infer(n_evidence=n_evi) + + check_consistent_sample(res, res_diff, res_same) + + assert not np.array_equal(res.x_min, res_diff.x_min) + assert np.array_equal(res.x_min, res_same.x_min) + + +@pytest.mark.usefixtures('with_all_clients') +def test_bolfi(ma2): + bs = 2 + n_samples = 4 + upd_int = 1 + n_evi = 16 + init_evi = 10 + bounds = {'t1':(-2,2), 't2':(-1, 1)} + anv = .1 + nchains = 2 + + bolfi = elfi.BOLFI(ma2, 'd', initial_evidence=init_evi, update_interval=upd_int, + batch_size=bs, bounds=bounds, acq_noise_var=anv) + sample = bolfi.sample(n_samples, n_evidence=n_evi, n_chains=nchains) + seed = bolfi.seed + + bolfi = elfi.BOLFI(ma2, 'd', initial_evidence=init_evi, update_interval=upd_int, + batch_size=bs, bounds=bounds, acq_noise_var=anv) + sample_diff = bolfi.sample(n_samples, n_evidence=n_evi, n_chains=nchains) + + bolfi = elfi.BOLFI(ma2, 'd', seed=seed, initial_evidence=init_evi, + update_interval=upd_int, batch_size=bs, bounds=bounds, + acq_noise_var=anv) + sample_same = bolfi.sample(n_samples, n_evidence=n_evi, n_chains=nchains) + + check_consistent_sample(sample, sample_diff, sample_same) diff --git a/tests/functional/test_inference.py b/tests/functional/test_inference.py index 62f45b88..dee8da00 100644 --- a/tests/functional/test_inference.py +++ b/tests/functional/test_inference.py @@ -94,9 +94,8 @@ def test_smc(): assert res.populations[-1].n_batches < 6 -@pytest.mark.usefixtures('skip_travis') # very, very slow in Travis, but ok locally @slow -@pytest.mark.usefixtures('with_all_clients') +@pytest.mark.usefixtures('with_all_clients', 'skip_travis') def test_BOLFI(): m, true_params = setup_ma2_with_informative_data() @@ -105,15 +104,15 @@ def test_BOLFI(): log_d = NodeReference(m['d'], state=dict(_operation=np.log), model=m, name='log_d') bolfi = elfi.BOLFI(log_d, initial_evidence=20, update_interval=10, batch_size=5, - bounds={'t1':(-2,2), 't2':(-1, 1)}, acq_noise_cov=.1) + bounds={'t1':(-2,2), 't2':(-1, 1)}, acq_noise_var=.1) n = 300 res = bolfi.infer(300) assert bolfi.target_model.n_evidence == 300 acq_x = bolfi.target_model._gp.X # check_inference_with_informative_data(res, 1, true_params, error_bound=.2) - assert np.abs(res.x['t1'] - true_params['t1']) < 0.2 - assert np.abs(res.x['t2'] - true_params['t2']) < 0.2 + assert np.abs(res.x_min['t1'] - true_params['t1']) < 0.2 + assert np.abs(res.x_min['t2'] - true_params['t2']) < 0.2 # Test that you can continue the inference where we left off res = bolfi.infer(n+10) diff --git a/tests/functional/test_randomness.py b/tests/functional/test_randomness.py index 38598fd5..53fd3285 100644 --- a/tests/functional/test_randomness.py +++ b/tests/functional/test_randomness.py @@ -7,6 +7,9 @@ from elfi.utils import get_sub_seed + + + def test_randomness(simple_model): k1 = simple_model['k1'] diff --git a/tests/unit/test_bo.py b/tests/unit/test_bo.py index 85e20f84..bb45e6ad 100644 --- a/tests/unit/test_bo.py +++ b/tests/unit/test_bo.py @@ -3,6 +3,8 @@ import numpy as np import elfi +import elfi.methods.bo.acquisition as acquisition +from elfi.methods.bo.gpy_regression import GPyRegression @pytest.mark.usefixtures('with_all_clients') @@ -19,7 +21,7 @@ def test_BO(ma2): bounds=bounds) assert bo.target_model.n_evidence == n_init assert bo.n_evidence == n_init - assert bo._n_precomputed == n_init + assert bo.n_precomputed_evidence == n_init assert bo.n_initial_evidence == n_init n1 = 5 @@ -27,7 +29,7 @@ def test_BO(ma2): assert bo.target_model.n_evidence == n_init + n1 assert bo.n_evidence == n_init + n1 - assert bo._n_precomputed == n_init + assert bo.n_precomputed_evidence == n_init assert bo.n_initial_evidence == n_init n2 = 5 @@ -35,11 +37,12 @@ def test_BO(ma2): assert bo.target_model.n_evidence == n_init + n1 + n2 assert bo.n_evidence == n_init + n1 + n2 - assert bo._n_precomputed == n_init + assert bo.n_precomputed_evidence == n_init assert bo.n_initial_evidence == n_init assert np.array_equal(bo.target_model._gp.X[:n_init, 0], res_init.samples_list[0]) + @pytest.mark.usefixtures('with_all_clients') def test_BO_works_with_zero_init_samples(ma2): log_d = elfi.Operation(np.log, ma2['d'], name='log_d') @@ -49,22 +52,23 @@ def test_BO_works_with_zero_init_samples(ma2): bounds=bounds) assert bo.target_model.n_evidence == 0 assert bo.n_evidence == 0 - assert bo._n_precomputed == 0 + assert bo.n_precomputed_evidence == 0 assert bo.n_initial_evidence == 0 samples = 4 bo.infer(samples) assert bo.target_model.n_evidence == samples assert bo.n_evidence == samples - assert bo._n_precomputed == 0 + assert bo.n_precomputed_evidence == 0 assert bo.n_initial_evidence == 0 + def test_acquisition(): n_params = 2 n = 10 n2 = 5 parameter_names = ['a', 'b'] bounds = {'a':[-2, 3], 'b':[5, 6]} - target_model = elfi.methods.bo.gpy_regression.GPyRegression(parameter_names, bounds=bounds) + target_model = GPyRegression(parameter_names, bounds=bounds) x1 = np.random.uniform(*bounds['a'], n) x2 = np.random.uniform(*bounds['b'], n) x = np.column_stack((x1, x2)) @@ -72,26 +76,26 @@ def test_acquisition(): target_model.update(x, y) # check acquisition without noise - acq_noise_cov = 0 + acq_noise_var = 0 t = 1 - acquisition_method = elfi.methods.bo.acquisition.LCBSC(target_model, noise_cov=acq_noise_cov) + acquisition_method = acquisition.LCBSC(target_model, noise_var=acq_noise_var) new = acquisition_method.acquire(n2, t=t) assert np.allclose(new[1:, 0], new[0, 0]) assert np.allclose(new[1:, 1], new[0, 1]) # check acquisition with scalar noise - acq_noise_cov = 2 + acq_noise_var = 2 t = 1 - acquisition_method = elfi.methods.bo.acquisition.LCBSC(target_model, noise_cov=acq_noise_cov) + acquisition_method = acquisition.LCBSC(target_model, noise_var=acq_noise_var) new = acquisition_method.acquire(n2, t=t) assert new.shape == (n2, n_params) assert np.all((new[:, 0] >= bounds['a'][0]) & (new[:, 0] <= bounds['a'][1])) assert np.all((new[:, 1] >= bounds['b'][0]) & (new[:, 1] <= bounds['b'][1])) - # check acquisition with diagonal covariance - acq_noise_cov = np.random.uniform(0, 5, size=2) + # check acquisition with separate variance for dimensions + acq_noise_var = np.random.uniform(0, 5, size=2) t = 1 - acquisition_method = elfi.methods.bo.acquisition.LCBSC(target_model, noise_cov=acq_noise_cov) + acquisition_method = acquisition.LCBSC(target_model, noise_var=acq_noise_var) new = acquisition_method.acquire(n2, t=t) assert new.shape == (n2, n_params) assert np.all((new[:, 0] >= bounds['a'][0]) & (new[:, 0] <= bounds['a'][1])) @@ -102,15 +106,13 @@ def test_acquisition(): acq_noise_cov += acq_noise_cov.T acq_noise_cov += n_params * np.eye(n_params) t = 1 - acquisition_method = elfi.methods.bo.acquisition.LCBSC(target_model, noise_cov=acq_noise_cov) - new = acquisition_method.acquire(n2, t=t) - assert new.shape == (n2, n_params) - assert np.all((new[:, 0] >= bounds['a'][0]) & (new[:, 0] <= bounds['a'][1])) - assert np.all((new[:, 1] >= bounds['b'][0]) & (new[:, 1] <= bounds['b'][1])) + with pytest.raises(ValueError): + acquisition.LCBSC(target_model, noise_var=acq_noise_cov) # test Uniform Acquisition t = 1 - acquisition_method = elfi.methods.bo.acquisition.UniformAcquisition(target_model, noise_cov=acq_noise_cov) + acquisition_method = acquisition.UniformAcquisition(target_model, + noise_var=acq_noise_var) new = acquisition_method.acquire(n2, t=t) assert new.shape == (n2, n_params) assert np.all((new[:, 0] >= bounds['a'][0]) & (new[:, 0] <= bounds['a'][1])) diff --git a/tests/unit/test_methods.py b/tests/unit/test_methods.py index 0a3eec68..089ab0a3 100644 --- a/tests/unit/test_methods.py +++ b/tests/unit/test_methods.py @@ -20,7 +20,7 @@ def test_smc_prior_use(ma2): N = 1000 smc = elfi.SMC(ma2['d'], batch_size=20000) res = smc.sample(N, thresholds=thresholds) - dens = res.populations[0].outputs[smc.prior_pdf] + dens = res.populations[0].outputs[smc.prior_logpdf] # Test that the density is uniform assert np.allclose(dens, dens[0]) From f99f77d68b7fe0dfd13a871333492dabcc0ccb72 Mon Sep 17 00:00:00 2001 From: Jarno Lintusaari Date: Mon, 3 Jul 2017 11:23:37 +0300 Subject: [PATCH 33/38] Async mode for BOLFI (#198) --- elfi/methods/parameter_inference.py | 17 ++++++++++++++--- tests/unit/test_bo.py | 10 ++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/elfi/methods/parameter_inference.py b/elfi/methods/parameter_inference.py index 211e879d..2ae20173 100644 --- a/elfi/methods/parameter_inference.py +++ b/elfi/methods/parameter_inference.py @@ -717,7 +717,7 @@ class BayesianOptimization(ParameterInference): def __init__(self, model, target_name=None, bounds=None, initial_evidence=None, update_interval=10, target_model=None, acquisition_method=None, acq_noise_var=0, exploration_rate=10, batch_size=1, - batches_per_acquisition=None, **kwargs): + batches_per_acquisition=None, async=False, **kwargs): """ Parameters ---------- @@ -744,8 +744,14 @@ def __init__(self, model, target_name=None, bounds=None, initial_evidence=None, batch_size : int, optional Elfi batch size. Defaults to 1. batches_per_acquisition : int, optional - How many batches will be acquired at the same time. Defaults to - max_parallel_batches. + How many batches will be requested from the acquisition function at one go. + Defaults to max_parallel_batches. + async : bool + Allow acquisitions to be made asynchronously, i.e. do not wait for all the + results from the previous acquisition before making the next. This can be more + efficient with a large amount of workers (e.g. in cluster environments) but + forgoes the guarantee for the exactly same result with the same initial + conditions (e.g. the seed). Default False. **kwargs """ @@ -778,6 +784,7 @@ def __init__(self, model, target_name=None, bounds=None, initial_evidence=None, self.n_initial_evidence = n_initial self.n_precomputed_evidence = n_precomputed self.update_interval = update_interval + self.async = async self.state['n_evidence'] = self.n_precomputed_evidence self.state['last_GP_update'] = self.n_initial_evidence @@ -900,6 +907,10 @@ def _allow_submit(self, batch_index): if not super(BayesianOptimization, self)._allow_submit(batch_index): return False + if self.async: + return True + + # Allow submitting freely as long we are still submitting initial evidence t = self._get_acquisition_index(batch_index) if t < 0: return True diff --git a/tests/unit/test_bo.py b/tests/unit/test_bo.py index bb45e6ad..e6b10620 100644 --- a/tests/unit/test_bo.py +++ b/tests/unit/test_bo.py @@ -43,6 +43,16 @@ def test_BO(ma2): assert np.array_equal(bo.target_model._gp.X[:n_init, 0], res_init.samples_list[0]) +@pytest.mark.usefixtures('with_all_clients') +def test_async(ma2): + bounds = {n:(-2, 2) for n in ma2.parameter_names} + bo = elfi.BayesianOptimization(ma2, 'd', initial_evidence=0, + update_interval=2, batch_size=2, + bounds=bounds, async=True) + samples = 5 + bo.infer(samples) + + @pytest.mark.usefixtures('with_all_clients') def test_BO_works_with_zero_init_samples(ma2): log_d = elfi.Operation(np.log, ma2['d'], name='log_d') From a013fd369068484c7ea248c33eb2a3075b909361 Mon Sep 17 00:00:00 2001 From: Henri Vuollekoski Date: Mon, 3 Jul 2017 12:19:17 +0300 Subject: [PATCH 34/38] Updated docs (#196) * Update docs, bump version * Address comments to PR 196 --- Makefile | 21 +- docs/index.rst | 1 + docs/quickstart.rst | 30 +- docs/usage/BOLFI.rst | 315 +++++++++++++++++ docs/usage/external.rst | 107 +++--- docs/usage/parallelization.rst | 73 ++-- docs/usage/tutorial.rst | 527 +++++++--------------------- elfi/__init__.py | 3 +- elfi/methods/parameter_inference.py | 2 +- 9 files changed, 557 insertions(+), 522 deletions(-) create mode 100644 docs/usage/BOLFI.rst diff --git a/Makefile b/Makefile index 9370cb41..ce7f881d 100644 --- a/Makefile +++ b/Makefile @@ -69,20 +69,23 @@ docs: ## generate Sphinx HTML documentation, including API docs $(MAKE) -C docs html # $(BROWSER) docs/_build/html/index.html -CONTENT_URL := http://research.cs.aalto.fi/pml/software/elfi/docs/0.5/ +CONTENT_URL := http://research.cs.aalto.fi/pml/software/elfi/docs/0.6/ notebook-docs: ## Conver notebooks to rst docs. Assumes you have them in `notebooks` directory. - jupyter nbconvert --to rst notebooks/quickstart.ipynb --output-dir docs - sed -i 's|\(quickstart_files/quickstart.*\.\)|'${CONTENT_URL}'\1|g' docs/quickstart.rst + jupyter nbconvert --to rst ../notebooks/quickstart.ipynb --output-dir docs + sed -i '' 's|\(quickstart_files/quickstart.*\.\)|'${CONTENT_URL}'\1|g' docs/quickstart.rst - jupyter nbconvert --to rst notebooks/tutorial.ipynb --output-dir docs/usage - sed -i 's|\(tutorial_files/tutorial.*\.\)|'${CONTENT_URL}usage/'\1|g' docs/usage/tutorial.rst + jupyter nbconvert --to rst ../notebooks/tutorial.ipynb --output-dir docs/usage + sed -i '' 's|\(tutorial_files/tutorial.*\.\)|'${CONTENT_URL}usage/'\1|g' docs/usage/tutorial.rst - jupyter nbconvert --to rst notebooks/parallelization.ipynb --output-dir docs/usage - sed -i 's|\(parallelization_files/parallelization.*\.\)|'${CONTENT_URL}usage/'\1|g' docs/usage/parallelization.rst + jupyter nbconvert --to rst ../notebooks/BOLFI.ipynb --output-dir docs/usage + sed -i '' 's|\(BOLFI_files/BOLFI.*\.\)|'${CONTENT_URL}usage/'\1|g' docs/usage/BOLFI.rst - jupyter nbconvert --to rst notebooks/non_python_operations.ipynb --output-dir docs/usage --output=external - sed -i 's|\(external_files/external.*\.\)|'${CONTENT_URL}usage/'\1|g' docs/usage/external.rst + jupyter nbconvert --to rst ../notebooks/parallelization.ipynb --output-dir docs/usage + sed -i '' 's|\(parallelization_files/parallelization.*\.\)|'${CONTENT_URL}usage/'\1|g' docs/usage/parallelization.rst + + jupyter nbconvert --to rst ../notebooks/non_python_operations.ipynb --output-dir docs/usage --output=external + sed -i '' 's|\(external_files/external.*\.\)|'${CONTENT_URL}usage/'\1|g' docs/usage/external.rst # release: clean ## package and upload a release # python setup.py sdist upload diff --git a/docs/index.rst b/docs/index.rst index 6c16fefa..7c83e4d9 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -51,6 +51,7 @@ ELFI also has the following non LFI methods: usage/tutorial usage/parallelization + usage/BOLFI usage/external usage/implementing-methods diff --git a/docs/quickstart.rst b/docs/quickstart.rst index ca8a2632..2536b0de 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -2,10 +2,12 @@ Quickstart ========== -First ensure you have `installed `__ Python 3.5 (or -greater) and ELFI. After installation you can start using ELFI: +First ensure you have +`installed `__ +Python 3.5 (or greater) and ELFI. After installation you can start using +ELFI: -.. code:: python +.. code:: ipython3 import elfi @@ -13,7 +15,7 @@ ELFI includes an easy to use generative modeling syntax, where the generative model is specified as a directed acyclic graph (DAG). Let’s create two prior nodes: -.. code:: python +.. code:: ipython3 mu = elfi.Prior('uniform', -2, 4) sigma = elfi.Prior('uniform', 1, 4) @@ -28,7 +30,7 @@ summary statistics for the data. As an example, lets define the simulator as 30 draws from a Gaussian distribution with a given mean and standard deviation. Let's use mean and variance as our summaries: -.. code:: python +.. code:: ipython3 import scipy.stats as ss import numpy as np @@ -46,7 +48,7 @@ standard deviation. Let's use mean and variance as our summaries: Let’s now assume we have some observed data ``y0`` (here we just create some with the simulator): -.. code:: python +.. code:: ipython3 # Set the generating parameters that we will try to infer mean0 = 1 @@ -71,7 +73,7 @@ Now we have all the components needed. Let’s complete our model by adding the simulator, the observed data, summaries and a distance to our model: -.. code:: python +.. code:: ipython3 # Add the simulator node and observed data to the model sim = elfi.Simulator(simulator, mu, sigma, observed=y0) @@ -90,7 +92,7 @@ model: -.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.5/quickstart_files/quickstart_9_0.svg +.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.6/quickstart_files/quickstart_9_0.svg @@ -99,7 +101,7 @@ We can try to infer the true generating parameters ``mean0`` and Rejection sampling and sample 1000 samples from the approximate posterior using threshold value 0.5: -.. code:: python +.. code:: ipython3 rej = elfi.Rejection(d, batch_size=10000, seed=30052017) res = rej.sample(1000, threshold=.5) @@ -111,14 +113,14 @@ posterior using threshold value 0.5: Method: Rejection Number of posterior samples: 1000 Number of simulations: 120000 - Threshold: 0.498 - Posterior means: mu: 0.726, sigma: 3.09 + Threshold: 0.492 + Posterior means: mu: 0.748, sigma: 3.1 Let's plot also the marginal distributions for the parameters: -.. code:: python +.. code:: ipython3 import matplotlib.pyplot as plt res.plot_marginals() @@ -126,7 +128,5 @@ Let's plot also the marginal distributions for the parameters: -.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.5/quickstart_files/quickstart_13_0.png +.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.6/quickstart_files/quickstart_13_0.png - -For a more details, please see the `tutorial `__. diff --git a/docs/usage/BOLFI.rst b/docs/usage/BOLFI.rst new file mode 100644 index 00000000..95ed4092 --- /dev/null +++ b/docs/usage/BOLFI.rst @@ -0,0 +1,315 @@ + +This tutorial is generated from a `Jupyter `__ +notebook that can be found +`here `__. + +BOLFI +----- + +In practice inference problems often have a complicated and +computationally heavy simulator, and one simply cannot run it for +millions of times. The Bayesian Optimization for Likelihood-Free +Inference `BOLFI `__ +framework is likely to prove useful in such situation: a statistical +model (usually `Gaussian +process `__, GP) is +created for the discrepancy, and its minimum is inferred with `Bayesian +optimization `__. +This approach typically reduces the number of required simulator calls +by several orders of magnitude. + +This tutorial demonstrates how to use BOLFI to do LFI in ELFI. + +.. code:: ipython3 + + import numpy as np + import scipy.stats + import matplotlib + import matplotlib.pyplot as plt + + %matplotlib inline + %precision 2 + + import logging + logging.basicConfig(level=logging.INFO) + + # Set an arbitrary global seed to keep the randomly generated quantities the same + seed = 20170703 + np.random.seed(seed) + + import elfi + +Although BOLFI is best used with complicated simulators, for +demonstration purposes we will use the familiar MA2 model introduced in +the basic tutorial, and load it from ready-made examples: + +.. code:: ipython3 + + from elfi.examples import ma2 + model = ma2.get_model(seed_obs=seed) + elfi.draw(model) + + + + +.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.6/usage/BOLFI_files/BOLFI_5_0.svg + + + +Fitting the surrogate model +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Now we can immediately proceed with the inference. However, when dealing +with a Gaussian process, it may be beneficial to take a logarithm of the +discrepancies in order to reduce the effect that high discrepancies have +on the GP. (Sometimes you may want to add a small constant to avoid very +negative or even -Inf distances occurring especially if it is likely +that there can be exact matches between simulated and observed data.) In +ELFI such transformed node can be created easily: + +.. code:: ipython3 + + log_d = elfi.Operation(np.log, model['d']) + +As BOLFI is a more advanced inference method, its interface is also a +bit more involved as compared to for example rejection sampling. But not +much: Using the same graphical model as earlier, the inference could +begin by defining a Gaussian process (GP) model, for which ELFI uses the +`GPy `__ library. This could be +given as an ``elfi.GPyRegression`` object via the keyword argument +``target_model``. In this case, we are happy with the default that ELFI +creates for us when we just give it each parameter some ``bounds`` as a +dictionary. + +Other notable arguments include the ``initial_evidence``, which gives +the number of initialization points sampled straight from the priors +before starting to optimize the acquisition of points, +``update_interval`` which defines how often the GP hyperparameters are +optimized, and ``acq_noise_var`` which defines the diagonal covariance +of noise added to the acquired points. + +.. code:: ipython3 + + bolfi = elfi.BOLFI(log_d, batch_size=5, initial_evidence=20, update_interval=10, + bounds={'t1':(-2, 2), 't2':(-1, 1)}, acq_noise_var=[0.1, 0.1], seed=seed) + +Sometimes you may have some samples readily available. You could then +initialize the GP model with a dictionary of previous results by giving +``initial_evidence=result.outputs``. + +The BOLFI class can now try to ``fit`` the surrogate model (the GP) to +the relationship between parameter values and the resulting +discrepancies. We'll request only 100 evidence points (including the +``initial_evidence`` defined above). + +.. code:: ipython3 + + %time post = bolfi.fit(n_evidence=100) + + +.. parsed-literal:: + + INFO:elfi.methods.parameter_inference:BOLFI: Fitting the surrogate model... + INFO:elfi.methods.posteriors:Using optimized minimum value (-1.4121) of the GP discrepancy mean function as a threshold + + +.. parsed-literal:: + + CPU times: user 13.2 s, sys: 139 ms, total: 13.3 s + Wall time: 7.09 s + + +(More on the returned ``BolfiPosterior`` object +`below <#BOLFI-Posterior>`__.) + +Note that in spite of the very few simulator runs, fitting the model +took longer than any of the previous methods. Indeed, BOLFI is intended +for scenarios where the simulator takes a lot of time to run. + +The fitted ``target_model`` uses the GPy library, and can be +investigated further: + +.. code:: ipython3 + + bolfi.target_model + + + + +.. parsed-literal:: + + + Name : GP regression + Objective : 92.664837723526 + Number of Parameters : 4 + Number of Optimization Parameters : 4 + Updates : True + Parameters: + GP_regression.  | value | constraints | priors + sum.rbf.variance  | 0.326569075912 | +ve | Ga(0.096, 1) + sum.rbf.lengthscale  | 0.552572833732 | +ve | Ga(1.3, 1) + sum.bias.variance  | 0.0878317664626 | +ve | Ga(0.024, 1) + Gaussian_noise.variance | 0.21318627419 | +ve | + + + +.. code:: ipython3 + + bolfi.plot_state(); + + + +.. parsed-literal:: + + + + + +.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.6/usage/BOLFI_files/BOLFI_15_1.png + + +It may be useful to see the acquired parameter values and the resulting +discrepancies: + +.. code:: ipython3 + + bolfi.plot_discrepancy(); + + + +.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.6/usage/BOLFI_files/BOLFI_17_0.png + + +There could be an unnecessarily high number of points at parameter +bounds. These could probably be decreased by lowering the covariance of +the noise added to acquired points, defined by the optional +``acq_noise_var`` argument for the BOLFI constructor. Another +possibility could be to `add virtual derivative observations at the +borders `__, though not yet +implemented in ELFI. + +BOLFI Posterior +~~~~~~~~~~~~~~~ + +Above, the ``fit`` method returned a ``BolfiPosterior`` object +representing a BOLFI posterior (please see the +`paper `__ for +details). The ``fit`` method accepts a threshold parameter; if none is +given, ELFI will use the minimum value of discrepancy estimate mean. +Afterwards, one may request for a posterior with a different threshold: + +.. code:: ipython3 + + post2 = bolfi.extract_posterior(-1.) + +One can visualize a posterior directly (remember that the priors form a +triangle): + +.. code:: ipython3 + + post.plot(logpdf=True) + + + +.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.6/usage/BOLFI_files/BOLFI_23_0.png + + +Sampling +~~~~~~~~ + +Finally, samples from the posterior can be acquired with an MCMC +sampler. By default it runs 4 chains, and half of the requested samples +are spent in adaptation/warmup. Note that depending on the smoothness of +the GP approximation, the number of priors, their gradients etc., this +may be slow. + +.. code:: ipython3 + + %time result_BOLFI = bolfi.sample(1000, info_freq=1000) + + +.. parsed-literal:: + + INFO:elfi.methods.posteriors:Using optimized minimum value (-1.4121) of the GP discrepancy mean function as a threshold + INFO:elfi.methods.mcmc:NUTS: Performing 1000 iterations with 500 adaptation steps. + INFO:elfi.methods.mcmc:NUTS: Adaptation/warmup finished. Sampling... + INFO:elfi.methods.mcmc:NUTS: Acceptance ratio: 0.422. After warmup 80 proposals were outside of the region allowed by priors and rejected, decreasing acceptance ratio. + INFO:elfi.methods.mcmc:NUTS: Performing 1000 iterations with 500 adaptation steps. + INFO:elfi.methods.mcmc:NUTS: Adaptation/warmup finished. Sampling... + INFO:elfi.methods.mcmc:NUTS: Acceptance ratio: 0.414. After warmup 85 proposals were outside of the region allowed by priors and rejected, decreasing acceptance ratio. + INFO:elfi.methods.mcmc:NUTS: Performing 1000 iterations with 500 adaptation steps. + INFO:elfi.methods.mcmc:NUTS: Adaptation/warmup finished. Sampling... + INFO:elfi.methods.mcmc:NUTS: Acceptance ratio: 0.408. After warmup 73 proposals were outside of the region allowed by priors and rejected, decreasing acceptance ratio. + INFO:elfi.methods.mcmc:NUTS: Performing 1000 iterations with 500 adaptation steps. + INFO:elfi.methods.mcmc:NUTS: Adaptation/warmup finished. Sampling... + INFO:elfi.methods.mcmc:NUTS: Acceptance ratio: 0.404. After warmup 74 proposals were outside of the region allowed by priors and rejected, decreasing acceptance ratio. + + +.. parsed-literal:: + + 4 chains of 1000 iterations acquired. Effective sample size and Rhat for each parameter: + t1 1848.12533825 0.999883608451 + t2 2060.13369699 0.999774254928 + CPU times: user 1min 27s, sys: 1.21 s, total: 1min 28s + Wall time: 46.6 s + + +The sampling algorithms may be fine-tuned with some parameters. The +default +`No-U-Turn-Sampler `__ +is a sophisticated algorithm, and in some cases one may get warnings +about diverged proposals, which are signs that `something may be wrong +and should be +investigated `__. +It is good to understand the cause of these warnings although they don't +automatically mean that the results are unreliable. You could try +rerunning the ``sample`` method with a higher target probability +``target_prob`` during adaptation, as its default 0.6 may be inadequate +for a non-smooth posteriors, but this will slow down the sampling. + +Note also that since MCMC proposals outside the region allowed by either +the model priors or GP bounds are rejected, a tight domain may lead to +suboptimal overall acceptance ratio. In our MA2 case the prior defines a +triangle-shaped uniform support for the posterior, making it a good +example of a difficult model for the NUTS algorithm. + +Now we finally have a ``Sample`` object again, which has several +convenience methods: + +.. code:: ipython3 + + result_BOLFI + + + + +.. parsed-literal:: + + Method: BOLFI + Number of posterior samples: 2000 + Number of simulations: 100 + Threshold: -1.41 + Posterior means: t1: 0.564, t2: 0.28 + + + +.. code:: ipython3 + + result_BOLFI.plot_traces(); + + + +.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.6/usage/BOLFI_files/BOLFI_29_0.png + + +The black vertical lines indicate the end of warmup, which by default is +half of the number of iterations. + +.. code:: ipython3 + + result_BOLFI.plot_marginals(); + + + +.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.6/usage/BOLFI_files/BOLFI_31_0.png + diff --git a/docs/usage/external.rst b/docs/usage/external.rst index 152e3d17..007b9557 100644 --- a/docs/usage/external.rst +++ b/docs/usage/external.rst @@ -1,4 +1,8 @@ +This tutorial is generated from a `Jupyter `__ +notebook that can be found +`here `__. + Using non-Python operations =========================== @@ -10,12 +14,9 @@ briefly demonstrates how to do this in three common scenarios: - R function - MATLAB function -This tutorial is generated from a `Jupyter `__ -notebook that can be found -`here `__. Let's begin by -importing some libraries that we will be using: +Let's begin by importing some libraries that we will be using: -.. code:: python +.. code:: ipython3 import os import numpy as np @@ -44,7 +45,7 @@ use ``elfi.tools.external_operation`` tool to wrap executables as a Python callables (function). Let's first investigate how it works with a simple shell ``echo`` command: -.. code:: python +.. code:: ipython3 # Make an external command. {0} {1} are positional arguments and {seed} a keyword argument `seed`. command = 'echo {0} {1} {seed}' @@ -69,7 +70,7 @@ Currently ``echo_sim`` only accepts scalar arguments. In order to work in ELFI, ``echo_sim`` needs to be vectorized so that we can pass to it a vector of arguments. ELFI provides a handy tool for this as well: -.. code:: python +.. code:: ipython3 # Vectorize it with elfi tools echo_sim_vec = elfi.tools.vectorize(echo_sim) @@ -87,9 +88,9 @@ vector of arguments. ELFI provides a handy tool for this as well: .. parsed-literal:: - array([[ 4.81966633e-01, 0.00000000e+00, 1.08163575e+09], - [ 1.46447661e+00, 0.00000000e+00, 2.81716645e+09], - [ 8.85613616e-01, 0.00000000e+00, 3.66083810e+09]]) + array([[ 1.78154613e+00, 0.00000000e+00, 8.49425160e+08], + [ 1.48064044e+00, 0.00000000e+00, 8.49425160e+08], + [ 1.94733396e+00, 0.00000000e+00, 8.49425160e+08]]) @@ -142,7 +143,7 @@ efficiently. We will now reproduce Figure 6(a) in `*Lintusaari at al 2016* `__ *[2]* with ELFI. Let's start by defining some constants: -.. code:: python +.. code:: ipython3 # Fixed model parameters delta = 0 @@ -155,7 +156,7 @@ start by defining some constants: Let's build the beginning of a new model for the birth rate :math:`\alpha` as the only unknown -.. code:: python +.. code:: ipython3 m = elfi.ElfiModel(name='bdm') elfi.Prior('uniform', .005, 2, model=m, name='alpha') @@ -169,7 +170,7 @@ Let's build the beginning of a new model for the birth rate -.. code:: python +.. code:: ipython3 # Get the BDM source directory sources_path = elfi.examples.bdm.get_sources_path() @@ -184,14 +185,12 @@ Let's build the beginning of a new model for the birth rate .. parsed-literal:: - make: Entering directory '/l/lintusj1/notebooks-elfi/resources/cpp' - g++ bdm.cpp --std=c++0x -O -Wall -o bdm - make: Leaving directory '/l/lintusj1/notebooks-elfi/resources/cpp' + g++ bdm.cpp --std=c++0x -O -Wall -o bdm .. note:: The source code for the BDM simulator comes with ELFI. You can get the directory with `elfi.examples.bdm.get_source_directory()`. Under unix-like systems it can be compiled with just typing `make` to console in the source directory. For windows systems, you need to have some C++ compiler available to compile it. -.. code:: python +.. code:: ipython3 # Test the executable (assuming we have the executable `bdm` in the working directory) sim = elfi.tools.external_operation('./bdm {0} {1} {2} {3} --seed {seed} --mode 1') @@ -219,7 +218,7 @@ efficient would be to write a native Python module with C++ but it's beyond the scope of this article. So let's work through files which is a fairly common situation especially with existing software. -.. code:: python +.. code:: ipython3 # Assuming we have the executable `bdm` in the working directory command = './bdm {filename} --seed {seed} --mode 1 > {output_filename}' @@ -271,7 +270,7 @@ informative filenames, we ask ELFI to provide the operation some meta information. That will be available under the ``meta`` keyword (see the ``prepare_inputs`` function above): -.. code:: python +.. code:: ipython3 # Create the simulator bdm_node = elfi.Simulator(bdm, m['alpha'], delta, tau, N, observed=y_obs, name='sim') @@ -285,11 +284,11 @@ information. That will be available under the ``meta`` keyword (see the -.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.5/usage/external_files/external_20_0.svg +.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.6/usage/external_files/external_21_0.svg -.. code:: python +.. code:: ipython3 # Test it data = bdm_node.generate(3) @@ -298,9 +297,9 @@ information. That will be available under the ``meta`` keyword (see the .. parsed-literal:: - [[ 3 1 2 1 4 2 1 1 1 1 1 1 1 0 0 0 0 0 0 0] - [ 1 1 1 1 1 1 1 2 1 1 1 1 1 1 2 1 1 1 0 0] - [15 4 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0]] + [[12 1 1 2 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0] + [18 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0] + [19 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0]] Completing the BDM model @@ -311,7 +310,7 @@ We are now ready to finish up the BDM model. To reproduce Figure 6(a) in *[2]*, let's add different summaries and discrepancies to the model and run the inference for each of them: -.. code:: python +.. code:: ipython3 def T1(clusters): clusters = np.atleast_2d(clusters) @@ -339,18 +338,18 @@ run the inference for each of them: -.. code:: python +.. code:: ipython3 elfi.draw(m) -.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.5/usage/external_files/external_24_0.svg +.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.6/usage/external_files/external_25_0.svg -.. code:: python +.. code:: ipython3 # Save parameter and simulation results in memory to speed up the later inference pool = elfi.OutputPool(['alpha', 'sim']) @@ -369,15 +368,15 @@ run the inference for each of them: .. parsed-literal:: - CPU times: user 4.19 s, sys: 60 ms, total: 4.25 s - Wall time: 5.19 s - CPU times: user 24 ms, sys: 4 ms, total: 28 ms - Wall time: 26.3 ms - CPU times: user 28 ms, sys: 0 ns, total: 28 ms - Wall time: 28.9 ms + CPU times: user 2.95 s, sys: 96.3 ms, total: 3.05 s + Wall time: 5.05 s + CPU times: user 30.4 ms, sys: 1.7 ms, total: 32.1 ms + Wall time: 31.9 ms + CPU times: user 33.8 ms, sys: 728 µs, total: 34.5 ms + Wall time: 34.4 ms -.. code:: python +.. code:: ipython3 # Load a precomputed posterior based on an analytic solution (see Lintusaari et al 2016) matdata = sio.loadmat('./resources/bdm.mat') @@ -408,7 +407,7 @@ run the inference for each of them: -.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.5/usage/external_files/external_26_1.png +.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.6/usage/external_files/external_27_1.png Interfacing with R @@ -425,7 +424,7 @@ Here we demonstrate how to calculate the summary statistics used in the ELFI tutorial (autocovariances) using R's ``acf`` function for the MA2 model. -.. code:: python +.. code:: ipython3 import rpy2.robjects as robj from rpy2.robjects import numpy2ri as np2ri @@ -440,7 +439,7 @@ model. Let's create a Python function that wraps the R commands (please see the documentation of `rpy2 `__ for details): -.. code:: python +.. code:: ipython3 robj.r(''' # create a function `f` @@ -458,7 +457,7 @@ documentation of `rpy2 `__ for details): ans = apply(x, 1, f, lag=lag) return np.atleast_1d(ans) -.. code:: python +.. code:: ipython3 # Test it autocovR(np.array([[1,2,3,4], [4,5,6,7]]), 1) @@ -474,7 +473,7 @@ documentation of `rpy2 `__ for details): Load a ready made MA2 model: -.. code:: python +.. code:: ipython3 ma2 = elfi.examples.ma2.get_model(seed_obs=4) elfi.draw(ma2) @@ -482,13 +481,13 @@ Load a ready made MA2 model: -.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.5/usage/external_files/external_35_0.svg +.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.6/usage/external_files/external_36_0.svg Replace the summaries S1 and S2 with our R autocovariance function. -.. code:: python +.. code:: ipython3 # Replace with R autocov S1 = elfi.Summary(autocovR, ma2['MA2'], 1) @@ -508,8 +507,8 @@ Replace the summaries S1 and S2 with our R autocovariance function. Method: Rejection Number of posterior samples: 100 Number of simulations: 10000 - Threshold: 0.11 - Posterior means: t1: 0.597, t2: 0.168 + Threshold: 0.111 + Posterior means: t1: 0.599, t2: 0.177 @@ -522,20 +521,20 @@ MATLAB function using the official `MATLAB Python cd API `__. (Tested with MATLAB 2016b.) -.. code:: python +.. code:: ipython3 import matlab.engine A MATLAB session needs to be started (and stopped) separately: -.. code:: python +.. code:: ipython3 eng = matlab.engine.start_matlab() # takes a while... Similarly as with R, we have to write a piece of code to interface between MATLAB and Python: -.. code:: python +.. code:: ipython3 def euclidean_M(x, y): # MATLAB array initialized with Python's list @@ -548,7 +547,7 @@ between MATLAB and Python: d = np.atleast_1d(dM).reshape(-1) return d -.. code:: python +.. code:: ipython3 # Test it euclidean_M(np.array([[1,2,3], [6,7,8], [2,2,3]]), np.array([2,2,2])) @@ -564,7 +563,7 @@ between MATLAB and Python: Load a ready made MA2 model: -.. code:: python +.. code:: ipython3 ma2M = elfi.examples.ma2.get_model(seed_obs=4) elfi.draw(ma2M) @@ -572,13 +571,13 @@ Load a ready made MA2 model: -.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.5/usage/external_files/external_47_0.svg +.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.6/usage/external_files/external_48_0.svg Replace the summaries S1 and S2 with our R autocovariance function. -.. code:: python +.. code:: ipython3 # Replace with Matlab distance implementation d = elfi.Distance(euclidean_M, ma2M['S1'], ma2M['S2']) @@ -596,14 +595,14 @@ Replace the summaries S1 and S2 with our R autocovariance function. Method: Rejection Number of posterior samples: 100 Number of simulations: 10000 - Threshold: 0.111 - Posterior means: t1: 0.6, t2: 0.169 + Threshold: 0.113 + Posterior means: t1: 0.602, t2: 0.178 Finally, don't forget to quit the MATLAB session: -.. code:: python +.. code:: ipython3 eng.quit() diff --git a/docs/usage/parallelization.rst b/docs/usage/parallelization.rst index cdcc71da..7159a0c8 100644 --- a/docs/usage/parallelization.rst +++ b/docs/usage/parallelization.rst @@ -1,20 +1,27 @@ +This tutorial is generated from a `Jupyter `__ +notebook that can be found +`here `__. + Parallelization =============== Behind the scenes, ELFI can automatically parallelize the computational -inference via different clients. Currently ELFI has two clients: +inference via different clients. Currently ELFI includes three clients: - ``elfi.clients.native`` (activated by default): does not parallelize but makes it easy to test and debug your code. +- ``elfi.clients.multiprocessing``: basic local parallelization using + Python's built-in multiprocessing library - ``elfi.clients.ipyparallel``: `ipyparallel `__ based client that can parallelize from multiple cores up to a distributed cluster. -We will show in this tutorial how to activate and use the -``ipyparallel`` client with ELFI. This tutorial is generated from a -`Jupyter `__ notebook that can be found -`here `__. +A client is activated by importing the respective ELFI module. + +This tutorial shows how to activate and use the ``ipyparallel`` client +with ELFI. For local parallelization, the ``multiprocessing`` client is +simpler to use. Activating parallelization -------------------------- @@ -22,7 +29,7 @@ Activating parallelization To activate the ``ipyparallel`` client in ELFI you just need to import it: -.. code:: python +.. code:: ipython3 import elfi # This activates the parallelization with ipyparallel @@ -35,7 +42,7 @@ Before you can actually run things in parallel you also need to start an ``ipyparallel`` cluster. Below is an example of how to start a local cluster to the background using 4 CPU cores: -.. code:: python +.. code:: ipython3 !ipcluster start -n 4 --daemon @@ -50,11 +57,10 @@ cluster to the background using 4 CPU cores: Running parallel inference -------------------------- -We will run parallel inference for the MA2 model introduced in the -`tutorial `__. A ready made model can be imported from -``elfi.examples``: +We will run parallel inference for the MA2 model introduced in the basic +tutorial. A ready made model can be imported from ``elfi.examples``: -.. code:: python +.. code:: ipython3 from elfi.examples import ma2 model = ma2.get_model() @@ -64,14 +70,14 @@ We will run parallel inference for the MA2 model introduced in the -.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.5/usage/parallelization_files/parallelization_8_0.svg +.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.6/usage/parallelization_files/parallelization_9_0.svg Otherwise everything should be familiar, and ELFI handles everything for you regarding the parallelization. -.. code:: python +.. code:: ipython3 rej = elfi.Rejection(model, 'd', batch_size=10000, seed=20170530) @@ -80,20 +86,20 @@ operating system; it should show 4 (or whatever number you gave the ``ipcluster start`` command) Python processes doing heavy computation simultaneously. -.. code:: python +.. code:: ipython3 %time result = rej.sample(5000, n_sim=int(5e6)) # 5 million simulations .. parsed-literal:: - CPU times: user 3.07 s, sys: 168 ms, total: 3.24 s - Wall time: 15.2 s + CPU times: user 3.59 s, sys: 417 ms, total: 4 s + Wall time: 20.9 s -The ``Result`` object is also just like in the basic case: +The ``Sample`` object is also just like in the basic case: -.. code:: python +.. code:: ipython3 result.summary @@ -103,11 +109,11 @@ The ``Result`` object is also just like in the basic case: Method: Rejection Number of posterior samples: 5000 Number of simulations: 5000000 - Threshold: 0.0428 - Posterior means: t1: 0.771, t2: 0.513 + Threshold: 0.0336 + Posterior means: t1: 0.493, t2: 0.0332 -.. code:: python +.. code:: ipython3 import matplotlib.pyplot as plt result.plot_pairs() @@ -115,7 +121,7 @@ The ``Result`` object is also just like in the basic case: -.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.5/usage/parallelization_files/parallelization_15_0.png +.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.6/usage/parallelization_files/parallelization_16_0.png To summarize, the only thing that needed to be changed from the basic @@ -133,13 +139,13 @@ However, you may wish to experiment in an interactive session, using e.g. a jupyter notebook. ``ipyparallel`` makes it possible to interactively define functions for ELFI model and send them to workers. This is especially useful if you work from a jupyter notebook. We will -show a few examples. More information can be found from ``ipyparallel`` -documentation. +show a few examples. More information can be found from ```ipyparallel`` +documentation `__. In interactive sessions, you can change the model with built-in functionality without problems: -.. code:: python +.. code:: ipython3 d2 = elfi.Distance('cityblock', model['S1'], model['S2'], p=1) @@ -149,7 +155,7 @@ functionality without problems: But let's say you want to use your very own distance function in a jupyter notebook: -.. code:: python +.. code:: ipython3 def my_distance(x, y): # Note that interactively defined functions must use full module names, e.g. numpy instead of np @@ -163,7 +169,7 @@ This function definition is not automatically visible for the engines run in different processes and will not see interactively defined objects and functions. The below would therefore fail: -.. code:: python +.. code:: ipython3 # This will fail if you try it! # result3 = rej3.sample(1000, quantile=0.01) @@ -173,7 +179,7 @@ the scopes of the engines from interactive sessions. Because ``my_distance`` also uses ``numpy``, that must be imported in the engines as well: -.. code:: python +.. code:: ipython3 # Get the ipyparallel client ipyclient = elfi.get_client().ipp_client @@ -193,7 +199,7 @@ engines as well: The above may look a bit cumbersome, but now this works: -.. code:: python +.. code:: ipython3 rej3.sample(1000, quantile=0.01) # now this works @@ -205,8 +211,8 @@ The above may look a bit cumbersome, but now this works: Method: Rejection Number of posterior samples: 1000 Number of simulations: 100000 - Threshold: 0.0189 - Posterior means: t1: 0.771, t2: 0.483 + Threshold: 0.0117 + Posterior means: t1: 0.492, t2: 0.0389 @@ -218,12 +224,13 @@ engines. Remember to stop the ipcluster when done ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -.. code:: python +.. code:: ipython3 !ipcluster stop .. parsed-literal:: - 2017-05-30 18:21:46.329 [IPClusterStop] Stopping cluster [pid=3011921] with [signal=] + 2017-06-21 16:06:24.007 [IPClusterStop] Stopping cluster [pid=94248] with [signal=] + diff --git a/docs/usage/tutorial.rst b/docs/usage/tutorial.rst index 11f943c2..1b147e85 100644 --- a/docs/usage/tutorial.rst +++ b/docs/usage/tutorial.rst @@ -1,19 +1,18 @@ +This tutorial is generated from a `Jupyter `__ +notebook that can be found +`here `__. + ELFI tutorial ============= This tutorial covers the basics of using ELFI, i.e. how to make models, save results for later use and run different inference algorithms. -Please see also our other tutorials for -`parallelization `__ and using `non-Python -operations `__ in ELFI models. This tutorial is generated -from a `Jupyter `__ notebook that can be found -`here `__. Let's begin by importing libraries that we will use and specify some settings. -.. code:: python +.. code:: ipython3 import numpy as np import scipy.stats @@ -21,6 +20,7 @@ settings. import matplotlib.pyplot as plt %matplotlib inline + %precision 2 import logging logging.basicConfig(level=logging.INFO) @@ -52,7 +52,7 @@ In this tutorial, our task is to infer the parameters :math:`y` that originate from an MA(2) process. Let's define the MA(2) simulator as a Python function: -.. code:: python +.. code:: ipython3 def MA2(t1, t2, n_obs=100, batch_size=1, random_state=None): # Make inputs 2d arrays for numpy broadcasting with w @@ -98,7 +98,7 @@ parameter values :math:`\theta_1=0.6, \theta_2=0.2` as in `*Marin et al. and then try to infer these parameter values back based on the toy observed data alone. -.. code:: python +.. code:: ipython3 # true parameters t1_true = 0.6 @@ -116,7 +116,7 @@ observed data alone. -.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.5/usage/tutorial_files/tutorial_9_0.png +.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.6/usage/tutorial_files/tutorial_10_0.png Approximate Bayesian Computation @@ -165,7 +165,7 @@ conveniently. Often the target of the generative model is a distance between the simulated and observed data. To start creating our model, we will first import ELFI: -.. code:: python +.. code:: ipython3 import elfi @@ -173,10 +173,10 @@ As is usual in Bayesian statistical inference, we need to define *prior* distributions for the unknown parameters :math:`\theta_1, \theta_2`. In ELFI the priors can be any of the continuous and discrete distributions available in ``scipy.stats`` (for custom priors, see -`below <#custom_prior>`__). For simplicity, let's start by assuming that -both parameters follow ``Uniform(0, 2)``. +`below <#Custom-priors>`__). For simplicity, let's start by assuming +that both parameters follow ``Uniform(0, 2)``. -.. code:: python +.. code:: ipython3 # a node is defined by giving a distribution from scipy.stats together with any arguments (here 0 and 2) t1 = elfi.Prior(scipy.stats.uniform, 0, 2) @@ -189,7 +189,7 @@ and give the priors to it as arguments. This means that the parameters for the simulations will be drawn from the priors. Because we have the observed data available for this node, we provide it here as well: -.. code:: python +.. code:: ipython3 Y = elfi.Simulator(MA2, t1, t2, observed=y_obs) @@ -206,7 +206,7 @@ Here, we will apply the intuition arising from the definition of the MA(2) process, and use the autocovariances with lags 1 and 2 as the summary statistics: -.. code:: python +.. code:: ipython3 def autocov(x, lag=1): C = np.mean(x[:,lag:] * x[:,:-lag], axis=1) @@ -216,7 +216,7 @@ As is familiar by now, a ``Summary`` node is defined by giving the autocovariance function and the simulated data (which includes the observed as well): -.. code:: python +.. code:: ipython3 S1 = elfi.Summary(autocov, Y) S2 = elfi.Summary(autocov, Y, 2) # the optional keyword lag is given the value 2 @@ -225,7 +225,7 @@ Here, we choose the discrepancy as the common Euclidean L2-distance. ELFI can use many common distances directly from ``scipy.spatial.distance`` like this: -.. code:: python +.. code:: ipython3 # Finish the model with the final node that calculates the squared distance (S1_sim-S1_obs)**2 + (S2_sim-S2_obs)**2 d = elfi.Distance('euclidean', S1, S2) @@ -238,14 +238,14 @@ distance/discrepancy functions as well (see the documentation for Now that the inference model is defined, ELFI can visualize the model as a DAG. -.. code:: python +.. code:: ipython3 elfi.draw(d) # just give it a node in the model, or the model itself (d.model) -.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.5/usage/tutorial_files/tutorial_26_0.svg +.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.6/usage/tutorial_files/tutorial_27_0.svg @@ -280,7 +280,7 @@ internal book-keeping of pseudo-random number generation. Also the ``size`` keyword is needed (which in the simple cases is the same as the ``batch_size`` in the simulator definition). -.. code:: python +.. code:: ipython3 # define prior for t1 as in Marin et al., 2012 with t1 in range [-b, b] class CustomPrior_t1(elfi.Distribution): @@ -299,7 +299,7 @@ internal book-keeping of pseudo-random number generation. Also the These indeed sample from a triangle: -.. code:: python +.. code:: ipython3 t1_1000 = CustomPrior_t1.rvs(2, 1000) t2_1000 = CustomPrior_t2.rvs(t1_1000, 1, 1000) @@ -308,12 +308,12 @@ These indeed sample from a triangle: -.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.5/usage/tutorial_files/tutorial_32_0.png +.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.6/usage/tutorial_files/tutorial_33_0.png Let's change the earlier priors to the new ones in the inference model: -.. code:: python +.. code:: ipython3 t1.become(elfi.Prior(CustomPrior_t1, 2)) t2.become(elfi.Prior(CustomPrior_t2, t1, 1)) @@ -323,7 +323,7 @@ Let's change the earlier priors to the new ones in the inference model: -.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.5/usage/tutorial_files/tutorial_34_0.svg +.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.6/usage/tutorial_files/tutorial_35_0.svg @@ -351,7 +351,7 @@ Another optional keyword is the seed. This ensures that the outcome will be always the same for the same data and model. If you leave it out, a random seed will be taken. -.. code:: python +.. code:: ipython3 seed = 20170530 rej = elfi.Rejection(d, batch_size=10000, seed=seed) @@ -372,9 +372,9 @@ visualization on so that if you run this on a notebook you will see the posterior forming from a prior distribution. In this case most of the time is spent in drawing. -.. code:: python +.. code:: ipython3 - N = 10000 + N = 1000 vis = dict(xlim=[-2,2], ylim=[-1,1]) @@ -384,30 +384,30 @@ time is spent in drawing. -.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.5/usage/tutorial_files/tutorial_41_0.png +.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.6/usage/tutorial_files/tutorial_42_0.png .. raw:: html - Threshold: 0.11621562954973891 + Threshold: 0.116859716394976 .. parsed-literal:: - CPU times: user 31.6 s, sys: 916 ms, total: 32.5 s - Wall time: 32.4 s + CPU times: user 2.2 s, sys: 182 ms, total: 2.38 s + Wall time: 2.39 s -The ``sample`` method returns a ``Result`` object, which contains +The ``sample`` method returns a ``Sample`` object, which contains several attributes and methods. Most notably the attribute ``samples`` contains an ``OrderedDict`` (i.e. an ordered Python dictionary) of the -posterior numpy arrays for all the mnodel parameters (``elfi.Prior``\ s +posterior numpy arrays for all the model parameters (``elfi.Prior``\ s in the model). For rejection sampling, other attributes include e.g. the ``threshold``, which is the threshold value resulting in the requested quantile. -.. code:: python +.. code:: ipython3 result.samples['t1'].mean() @@ -416,13 +416,13 @@ quantile. .. parsed-literal:: - 0.5574475023785852 + 0.56 -The ``Result`` object includes a convenient ``summary`` method: +The ``Sample`` object includes a convenient ``summary`` method: -.. code:: python +.. code:: ipython3 result.summary @@ -430,10 +430,10 @@ The ``Result`` object includes a convenient ``summary`` method: .. parsed-literal:: Method: Rejection - Number of posterior samples: 10000 - Number of simulations: 1000000 - Threshold: 0.116 - Posterior means: t1: 0.557, t2: 0.221 + Number of posterior samples: 1000 + Number of simulations: 100000 + Threshold: 0.117 + Posterior means: t1: 0.556, t2: 0.219 Rejection sampling can also be performed with using a threshold or total @@ -442,22 +442,22 @@ draws from the prior for which the generated distance is below the threshold will be accepted as samples. Note that the simulator will run as long as it takes to generate the requested number of samples. -.. code:: python +.. code:: ipython3 %time result2 = rej.sample(N, threshold=0.2) - print(result2) # the Result object's __str__ contains the output from summary() + print(result2) # the Sample object's __str__ contains the output from summary() .. parsed-literal:: - CPU times: user 2.1 s, sys: 112 ms, total: 2.22 s - Wall time: 2.21 s + CPU times: user 215 ms, sys: 51.8 ms, total: 267 ms + Wall time: 269 ms Method: Rejection - Number of posterior samples: 10000 - Number of simulations: 340000 - Threshold: 0.2 - Posterior means: t1: 0.555, t2: 0.219 + Number of posterior samples: 1000 + Number of simulations: 40000 + Threshold: 0.185 + Posterior means: t1: 0.555, t2: 0.223 @@ -471,7 +471,7 @@ storing all outputs of any node in the model (not just the accepted samples). Let's save all outputs for ``t1``, ``t2``, ``S1`` and ``S2`` in our model: -.. code:: python +.. code:: ipython3 pool = elfi.OutputPool(['t1', 't2', 'S1', 'S2']) rej = elfi.Rejection(d, pool=pool) @@ -482,8 +482,8 @@ in our model: .. parsed-literal:: - CPU times: user 7.04 s, sys: 8 ms, total: 7.05 s - Wall time: 7.05 s + CPU times: user 6.14 s, sys: 102 ms, total: 6.24 s + Wall time: 6.38 s @@ -491,10 +491,10 @@ in our model: .. parsed-literal:: Method: Rejection - Number of posterior samples: 10000 + Number of posterior samples: 1000 Number of simulations: 1000000 - Threshold: 0.115 - Posterior means: t1: 0.556, t2: 0.218 + Threshold: 0.036 + Posterior means: t1: 0.56, t2: 0.227 @@ -503,7 +503,7 @@ to resimulate them. Above we saved the summaries to the pool, so we can change the distance node of the model without having to resimulate anything. Let's do that. -.. code:: python +.. code:: ipython3 # Replace the current distance with a cityblock (manhattan) distance and recreate the inference d.become(elfi.Distance('cityblock', S1, S2, p=1)) @@ -515,8 +515,8 @@ anything. Let's do that. .. parsed-literal:: - CPU times: user 956 ms, sys: 0 ns, total: 956 ms - Wall time: 954 ms + CPU times: user 848 ms, sys: 12.1 ms, total: 860 ms + Wall time: 895 ms @@ -524,10 +524,10 @@ anything. Let's do that. .. parsed-literal:: Method: Rejection - Number of posterior samples: 10000 + Number of posterior samples: 1000 Number of simulations: 1000000 - Threshold: 0.144 - Posterior means: t1: 0.557, t2: 0.219 + Threshold: 0.0447 + Posterior means: t1: 0.56, t2: 0.227 @@ -537,7 +537,7 @@ considered simulations stayed the same. We can also continue the inference by increasing the total number of simulations and only have to simulate the new ones: -.. code:: python +.. code:: ipython3 %time result5 = rej.sample(N, n_sim=1200000) result5 @@ -545,8 +545,8 @@ simulations and only have to simulate the new ones: .. parsed-literal:: - CPU times: user 2.33 s, sys: 8 ms, total: 2.34 s - Wall time: 2.33 s + CPU times: user 1.96 s, sys: 29.4 ms, total: 1.99 s + Wall time: 2.02 s @@ -554,10 +554,10 @@ simulations and only have to simulate the new ones: .. parsed-literal:: Method: Rejection - Number of posterior samples: 10000 + Number of posterior samples: 1000 Number of simulations: 1200000 - Threshold: 0.131 - Posterior means: t1: 0.556, t2: 0.22 + Threshold: 0.0409 + Posterior means: t1: 0.56, t2: 0.23 @@ -565,7 +565,7 @@ Above the results were saved into a python dictionary. If you store a lot of data to dictionaries, you will eventually run out of memory. Instead you can save the outputs to standard numpy .npy files: -.. code:: python +.. code:: ipython3 arraypool = elfi.store.ArrayPool(['t1', 't2', 'Y', 'd'], basepath='./output') rej = elfi.Rejection(d, pool=arraypool) @@ -574,30 +574,34 @@ Instead you can save the outputs to standard numpy .npy files: .. parsed-literal:: - CPU times: user 32 ms, sys: 8 ms, total: 40 ms - Wall time: 36.7 ms + CPU times: user 25.6 ms, sys: 2.58 ms, total: 28.2 ms + Wall time: 29.3 ms This stores the simulated data in binary ``npy`` format under ``arraypool.path``, and can be loaded with ``np.load``. -.. code:: python +.. code:: ipython3 # Let's flush the outputs to disk (alternatively you can close the pool) so that we can read them # while we still have the arraypool open. arraypool.flush() - !ls $arraypool.path + import os + os.listdir(arraypool.path) + + .. parsed-literal:: - d.npy t1.npy t2.npy Y.npy + ['d.npy', 't1.npy', 't2.npy', 'Y.npy'] + Now lets load all the parameters ``t1`` that were generated with numpy: -.. code:: python +.. code:: ipython3 np.load(arraypool.path + '/t1.npy') @@ -606,52 +610,56 @@ Now lets load all the parameters ``t1`` that were generated with numpy: .. parsed-literal:: - array([ 1.2228635 , 0.84295063, 1.52794226, ..., -0.15726344, - -0.72876666, -0.93158204]) + array([-0.51, 0.09, 0.72, ..., -1.23, 0.02, -0.66]) You can delete the files with: -.. code:: python +.. code:: ipython3 arraypool.delete() - !ls $arraypool.path # verify the deletion + # verify the deletion + try: + os.listdir(arraypool.path) + + except FileNotFoundError: + print("No such file or directory") .. parsed-literal:: - ls: cannot access './output/arraypool/4213416233': No such file or directory + No such file or directory Visualizing the results ----------------------- -Instances of ``Result`` contain methods for some basic plotting (these +Instances of ``Sample`` contain methods for some basic plotting (these are convenience methods to plotting functions defined under ``elfi.visualization``). For example one can plot the marginal distributions: -.. code:: python +.. code:: ipython3 result.plot_marginals(); -.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.5/usage/tutorial_files/tutorial_65_0.png +.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.6/usage/tutorial_files/tutorial_66_0.png Often "pairwise relationships" are more informative: -.. code:: python +.. code:: ipython3 result.plot_pairs(); -.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.5/usage/tutorial_files/tutorial_67_0.png +.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.6/usage/tutorial_files/tutorial_68_0.png Note that if working in a non-interactive environment, you can use e.g. @@ -673,7 +681,7 @@ used custom priors, so we have to specify a ``pdf`` function by ourselves. If we used standard priors, this step would not be needed. Let's modify the prior distribution classes: -.. code:: python +.. code:: ipython3 # define prior for t1 as in Marin et al., 2012 with t1 in range [-b, b] class CustomPrior_t1(elfi.Distribution): @@ -714,7 +722,7 @@ Run SMC ABC In ELFI, one can setup a SMC ABC sampler just like the Rejection sampler: -.. code:: python +.. code:: ipython3 smc = elfi.SMC(d, batch_size=10000, seed=seed) @@ -722,7 +730,7 @@ For sampling, one has to define the number of output samples, the number of populations and a *schedule* i.e. a list of quantiles to use for each population. In essence, a population is just refined rejection sampling. -.. code:: python +.. code:: ipython3 N = 1000 schedule = [0.7, 0.2, 0.05] @@ -731,70 +739,70 @@ population. In essence, a population is just refined rejection sampling. .. parsed-literal:: - INFO:elfi.methods.methods:---------------- Starting round 0 ---------------- - INFO:elfi.methods.methods:---------------- Starting round 1 ---------------- - INFO:elfi.methods.methods:---------------- Starting round 2 ---------------- + INFO:elfi.methods.parameter_inference:---------------- Starting round 0 ---------------- + INFO:elfi.methods.parameter_inference:---------------- Starting round 1 ---------------- + INFO:elfi.methods.parameter_inference:---------------- Starting round 2 ---------------- .. parsed-literal:: - CPU times: user 5.97 s, sys: 200 ms, total: 6.17 s - Wall time: 1.73 s + CPU times: user 1.36 s, sys: 241 ms, total: 1.6 s + Wall time: 1.62 s We can have summaries and plots of the results just like above: -.. code:: python +.. code:: ipython3 result_smc.summary .. parsed-literal:: - Method: SMC-ABC + Method: SMC Number of posterior samples: 1000 - Number of simulations: 180000 - Threshold: 0.0497 - Posterior means for final population: t1: 0.557, t2: 0.228 + Number of simulations: 190000 + Threshold: 0.0492 + Posterior means for final population: t1: 0.552, t2: 0.205 -The ``Result`` object returned by the SMC-ABC sampling contains also +The ``Sample`` object returned by the SMC-ABC sampling contains also some methods for investigating the evolution of populations, e.g.: -.. code:: python +.. code:: ipython3 result_smc.posterior_means_all_populations .. parsed-literal:: - Posterior means for population 0: t1: 0.544, t2: 0.229 - Posterior means for population 1: t1: 0.557, t2: 0.231 - Posterior means for population 2: t1: 0.557, t2: 0.228 + Posterior means for population 0: t1: 0.547, t2: 0.232 + Posterior means for population 1: t1: 0.559, t2: 0.23 + Posterior means for population 2: t1: 0.552, t2: 0.205 -.. code:: python +.. code:: ipython3 result_smc.plot_marginals_all_populations(bins=25, figsize=(8, 2), fontsize=12) -.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.5/usage/tutorial_files/tutorial_80_0.png +.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.6/usage/tutorial_files/tutorial_81_0.png -.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.5/usage/tutorial_files/tutorial_80_1.png +.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.6/usage/tutorial_files/tutorial_81_1.png -.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.5/usage/tutorial_files/tutorial_80_2.png +.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.6/usage/tutorial_files/tutorial_81_2.png Obviously one still has direct access to the samples as well, which allows custom plotting: -.. code:: python +.. code:: ipython3 n_populations = len(schedule) fig, ax = plt.subplots(ncols=n_populations, sharex=True, sharey=True, figsize=(16,6)) @@ -811,7 +819,7 @@ allows custom plotting: -.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.5/usage/tutorial_files/tutorial_82_0.png +.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.6/usage/tutorial_files/tutorial_83_0.png It can be seen that the populations iteratively concentrate more and @@ -824,305 +832,6 @@ previous samples with variance as twice the weighted empirical variance. However, the outliers carry zero weight, and have no effect on the estimates. -BOLFI ------ - -In practice inference problems often have a more complicated and -computationally heavy simulator than the model ``MA2`` here, and one -simply cannot run it for millions of times. The Bayesian Optimization -for Likelihood-Free Inference -`BOLFI `__ framework -is likely to prove useful in such situation: a statistical model (e.g. -`Gaussian process `__, -GP) is created for the discrepancy, and its minimum is inferred with -`Bayesian -optimization `__. -This approach typically reduces the number of required simulator calls -by several orders of magnitude. - -When dealing with a Gaussian process, it is advisable to take a -logarithm of the discrepancies in order to reduce the effect that high -discrepancies have on the GP. In ELFI such transformed node can be -created easily: - -.. code:: python - - log_d = elfi.Operation(np.log, d) - -As BOLFI is a more advanced inference method, its interface is also a -bit more involved. But not much: Using the same graphical model as -earlier, the inference could begin by defining a Gaussian process (GP) -model, for which we use the `GPy `__ -library. This could then be given via a keyword argument -``target_model``. In this case, we are happy with the default that ELFI -creates for us when we just give it each parameter some ``bounds``. - -Other notable arguments include the ``initial_evidence``, which defines -the number of initialization points sampled straight from the priors -before starting to optimize the acquisition of points, and -``update_interval`` which defines how often the GP hyperparameters are -optimized. - -.. code:: python - - bolfi = elfi.BOLFI(log_d, batch_size=5, initial_evidence=20, update_interval=10, - bounds=[(-2, 2), (-1, 1)], seed=seed) - -Sometimes you may have some samples readily available. You could then -initialize the GP model with a dictionary of previous results by giving -``initial_evidence=result1.outputs``. - -The BOLFI class can now try to ``fit`` the surrogate model (the GP) to -the relationship between parameter values and the resulting -discrepancies. We'll request 200 evidence points (including the -``initial_evidence`` defined above). - -.. code:: python - - %time bolfi.fit(n_evidence=200) - - -.. parsed-literal:: - - INFO:elfi.methods.methods:BOLFI: Fitting the surrogate model... - - -.. parsed-literal:: - - CPU times: user 42.7 s, sys: 620 ms, total: 43.4 s - Wall time: 13.9 s - - -Running this does not return anything currently, but internally the GP -is now fitted. - -Note that in spite of the very few simulator runs, fitting the model -took longer than any of the previous methods. Indeed, BOLFI is intended -for scenarios where the simulator takes a lot of time to run. - -The fitted ``target_model`` uses the GPy libarary, which can be -investigated further: - -.. code:: python - - bolfi.target_model - - - - -.. parsed-literal:: - - - Name : GP regression - Objective : 133.39773058984275 - Number of Parameters : 4 - Number of Optimization Parameters : 4 - Updates : True - Parameters: - GP_regression.  | value | constraints | priors - sum.rbf.variance  | 0.259297636885 | +ve | Ga(0.033, 1) - sum.rbf.lengthscale  | 0.607506322067 | +ve | Ga(1.3, 1) - sum.bias.variance  | 0.189445916354 | +ve | Ga(0.0082, 1) - Gaussian_noise.variance | 0.150210139296 | +ve | - - - -.. code:: python - - bolfi.plot_state(); - - - -.. parsed-literal:: - - - - - -.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.5/usage/tutorial_files/tutorial_95_1.png - - -It may be helpful to see the acquired parameter values and the resulting -discrepancies: - -.. code:: python - - bolfi.plot_discrepancy(); - - - -.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.5/usage/tutorial_files/tutorial_97_0.png - - -Note the high number of points at parameter bounds. These could probably -be decreased by lowering the covariance of the noise added to acquired -points, defined by the optional ``acq_noise_cov`` argument for the BOLFI -constructor. Another possibility could be to `add virtual derivative -observations at the borders `__, -though not yet implemented in ELFI. - -We can now infer the BOLFI posterior (please see the -`paper `__ for -details). The method accepts a threshold parameter; if none is given, -ELFI will use the minimum value of discrepancy estimate mean. - -.. code:: python - - post = bolfi.infer_posterior() - - -.. parsed-literal:: - - INFO:elfi.methods.results:Using minimum value of discrepancy estimate mean (-0.9865) as threshold - - -We can get estimates for *maximum a posteriori* and *maximum likelihood* -easily: - -.. code:: python - - post.MAP, post.ML - - - - -.. parsed-literal:: - - ((array([ 0.57407864, 0.09641608]), array([[ 0.69314718]])), - (array([ 0.57407869, 0.09641603]), array([[ 0.69314718]]))) - - - -We can visualize the posterior directly: - -.. code:: python - - post.plot() - - - -.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.5/usage/tutorial_files/tutorial_103_0.png - - -Finally, samples from the posterior can be acquired with an MCMC sampler -(note that depending on the smoothness of the GP approximation, this may -be slow): - -.. code:: python - - # bolfi.model.computation_context.seed = 10 - %time result_BOLFI = bolfi.sample(1000, target_prob=0.9) - - -.. parsed-literal:: - - INFO:elfi.methods.results:Using minimum value of discrepancy estimate mean (-0.9865) as threshold - INFO:elfi.methods.mcmc:NUTS: Performing 1000 iterations with 500 adaptation steps. - INFO:elfi.methods.mcmc:NUTS: Iterations performed: 100/1000... - INFO:elfi.methods.mcmc:NUTS: Iterations performed: 200/1000... - INFO:elfi.methods.mcmc:NUTS: Iterations performed: 300/1000... - INFO:elfi.methods.mcmc:NUTS: Iterations performed: 400/1000... - INFO:elfi.methods.mcmc:NUTS: Iterations performed: 500/1000... - INFO:elfi.methods.mcmc:NUTS: Adaptation/warmup finished. Sampling... - INFO:elfi.methods.mcmc:NUTS: Iterations performed: 600/1000... - INFO:elfi.methods.mcmc:NUTS: Iterations performed: 700/1000... - INFO:elfi.methods.mcmc:NUTS: Iterations performed: 800/1000... - INFO:elfi.methods.mcmc:NUTS: Iterations performed: 900/1000... - INFO:elfi.methods.mcmc:NUTS: Acceptance ratio: 0.215, Diverged proposals after warmup (i.e. n_adapt=500 steps): 8 - INFO:elfi.methods.mcmc:NUTS: Performing 1000 iterations with 500 adaptation steps. - INFO:elfi.methods.mcmc:NUTS: Iterations performed: 100/1000... - INFO:elfi.methods.mcmc:NUTS: Iterations performed: 200/1000... - INFO:elfi.methods.mcmc:NUTS: Iterations performed: 300/1000... - INFO:elfi.methods.mcmc:NUTS: Iterations performed: 400/1000... - INFO:elfi.methods.mcmc:NUTS: Iterations performed: 500/1000... - INFO:elfi.methods.mcmc:NUTS: Adaptation/warmup finished. Sampling... - INFO:elfi.methods.mcmc:NUTS: Iterations performed: 600/1000... - INFO:elfi.methods.mcmc:NUTS: Iterations performed: 700/1000... - INFO:elfi.methods.mcmc:NUTS: Iterations performed: 800/1000... - INFO:elfi.methods.mcmc:NUTS: Iterations performed: 900/1000... - INFO:elfi.methods.mcmc:NUTS: Acceptance ratio: 0.201, Diverged proposals after warmup (i.e. n_adapt=500 steps): 32 - INFO:elfi.methods.mcmc:NUTS: Performing 1000 iterations with 500 adaptation steps. - INFO:elfi.methods.mcmc:NUTS: Iterations performed: 100/1000... - INFO:elfi.methods.mcmc:NUTS: Iterations performed: 200/1000... - INFO:elfi.methods.mcmc:NUTS: Iterations performed: 300/1000... - INFO:elfi.methods.mcmc:NUTS: Iterations performed: 400/1000... - INFO:elfi.methods.mcmc:NUTS: Iterations performed: 500/1000... - INFO:elfi.methods.mcmc:NUTS: Adaptation/warmup finished. Sampling... - INFO:elfi.methods.mcmc:NUTS: Iterations performed: 600/1000... - INFO:elfi.methods.mcmc:NUTS: Iterations performed: 700/1000... - INFO:elfi.methods.mcmc:NUTS: Iterations performed: 800/1000... - INFO:elfi.methods.mcmc:NUTS: Iterations performed: 900/1000... - INFO:elfi.methods.mcmc:NUTS: Acceptance ratio: 0.223, Diverged proposals after warmup (i.e. n_adapt=500 steps): 10 - INFO:elfi.methods.mcmc:NUTS: Performing 1000 iterations with 500 adaptation steps. - INFO:elfi.methods.mcmc:NUTS: Iterations performed: 100/1000... - INFO:elfi.methods.mcmc:NUTS: Iterations performed: 200/1000... - INFO:elfi.methods.mcmc:NUTS: Iterations performed: 300/1000... - INFO:elfi.methods.mcmc:NUTS: Iterations performed: 400/1000... - INFO:elfi.methods.mcmc:NUTS: Iterations performed: 500/1000... - INFO:elfi.methods.mcmc:NUTS: Adaptation/warmup finished. Sampling... - INFO:elfi.methods.mcmc:NUTS: Iterations performed: 600/1000... - INFO:elfi.methods.mcmc:NUTS: Iterations performed: 700/1000... - INFO:elfi.methods.mcmc:NUTS: Iterations performed: 800/1000... - INFO:elfi.methods.mcmc:NUTS: Iterations performed: 900/1000... - INFO:elfi.methods.mcmc:NUTS: Acceptance ratio: 0.221, Diverged proposals after warmup (i.e. n_adapt=500 steps): 5 - - -.. parsed-literal:: - - 4 chains of 1000 iterations acquired. Effective sample size and Rhat for each parameter: - t1 649.78032882 1.00225844622 - t2 1037.40102821 1.00448229202 - CPU times: user 4min 11s, sys: 2.9 s, total: 4min 14s - Wall time: 1min 3s - - -The sampling algorithms may be fine-tuned with some parameters. If you -get a warning about diverged proposals, something may be wrong and -should be investigated. You can try rerunning the ``sample`` method with -a higher target probability ``target_prob`` during adaptation, as its -default 0.6 may be inadequate for a non-smooth GP, but this will slow -down the sampling. - -Now we finally have a ``Result`` object again, which has several -convenience methods: - -.. code:: python - - result_BOLFI - - - - -.. parsed-literal:: - - Method: BOLFI - Number of posterior samples: 2000 - Number of simulations: 200 - Threshold: -0.986 - Posterior means: t1: 0.599, t2: 0.0688 - - - -.. code:: python - - result_BOLFI.plot_traces(); - - - -.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.5/usage/tutorial_files/tutorial_108_0.png - - -The black vertical lines indicate the end of warmup, which by default is -half of the number of iterations. - -.. code:: python - - result_BOLFI.plot_marginals(); - - - -.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.5/usage/tutorial_files/tutorial_110_0.png - +That's it! See the other documentation for more advanced topics on e.g. +BOLFI, external simulators and parallelization. -That's it! See the other documentation for more topics on e.g. using -external simulators and parallelization. diff --git a/elfi/__init__.py b/elfi/__init__.py index 0f7a4722..e7dbea7f 100644 --- a/elfi/__init__.py +++ b/elfi/__init__.py @@ -11,9 +11,10 @@ from elfi.model.extensions import ScipyLikeDistribution as Distribution from elfi.store import OutputPool, ArrayPool from elfi.visualization.visualization import nx_draw as draw +from elfi.methods.bo.gpy_regression import GPyRegression __author__ = 'ELFI authors' __email__ = 'elfi-support@hiit.fi' # make sure __version_ is on the last non-empty line (read by setup.py) -__version__ = '0.5.0' +__version__ = '0.6.0' diff --git a/elfi/methods/parameter_inference.py b/elfi/methods/parameter_inference.py index 2ae20173..353d2ec8 100644 --- a/elfi/methods/parameter_inference.py +++ b/elfi/methods/parameter_inference.py @@ -175,7 +175,7 @@ def update(self, batch, batch_index): ------- None """ - logger.info('Received batch %d' % batch_index) + logger.debug('Received batch %d' % batch_index) self.state['n_batches'] += 1 self.state['n_sim'] += self.batch_size From 713ab5e52656a128fa7395c8334537c43905ac5a Mon Sep 17 00:00:00 2001 From: Henri Vuollekoski Date: Mon, 3 Jul 2017 14:02:22 +0300 Subject: [PATCH 35/38] Configure Travis to run on Mac OS X as well (#194) --- .travis.yml | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/.travis.yml b/.travis.yml index 3ed87940..e879401f 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,15 +1,28 @@ -language: python -python: - - "3.5" - - "3.6" +matrix: + include: + - os: linux + language: python + python: 3.5 + - os: linux + language: python + python: 3.6 + - os: osx + language: generic + before_install: + - mkdir -p /Users/travis/.matplotlib + - "echo 'backend: TkAgg' > /Users/travis/.matplotlib/matplotlibrc" + - brew update + - brew install python3 + - virtualenv env -p python3 + - source env/bin/activate cache: pip -# command to install dependencies + install: - pip install numpy - pip install -r requirements-dev.txt - pip install -e . -# command to run tests + script: - ipcluster start -n 2 --daemon #- travis_wait 20 make test From 62594591066d57258c53a42313d6aadf1951c08a Mon Sep 17 00:00:00 2001 From: Jarno Lintusaari Date: Mon, 3 Jul 2017 14:03:17 +0300 Subject: [PATCH 36/38] Small change to docs (#199) * Small change to docs * Some more --- docs/developer/architecture.rst | 12 ++++++------ docs/good-to-know.rst | 16 +++++++++------- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/docs/developer/architecture.rst b/docs/developer/architecture.rst index 1d11af61..30e9eadf 100644 --- a/docs/developer/architecture.rst +++ b/docs/developer/architecture.rst @@ -1,19 +1,19 @@ ELFI architecture ================= -Here we explain the internal representation of the generative model in ELFI. This +Here we explain the internal representation of the ELFI model. This representation contains everything that is needed to generate data, but is separate from e.g. the inference methods or the data storages. This information is aimed for developers and is not essential for using ELFI. We assume the reader is quite familiar with Python and has perhaps already read some of ELFI's source code. -The low level representation of the generative model is a ``networkx.DiGraph`` with nodes +The low level representation of the ELFI model is a ``networkx.DiGraph`` with nodes represented as Python dictionaries that are called node state dictionaries. This -representation is held in ``ElfiModel.source_net``. Before the generative model can be ran, -it needs to be compiled and loaded with data (e.g. observed data, precomputed data, batch +representation is held in ``ElfiModel.source_net``. Before the ELFI model can be ran, it +needs to be compiled and loaded with data (e.g. observed data, precomputed data, batch index, batch size etc). The compilation and loading of data is the responsibility of the -``Client`` implementation and makes it possible in essence to translate ``ElfiModel`` to any -kind of computational backend. Finally the class ``Executor`` is responsible for +``Client`` implementation and makes it possible in essence to translate ``ElfiModel`` to +any kind of computational backend. Finally the class ``Executor`` is responsible for running the compiled and loaded model and producing the outputs of the nodes. A user typically creates this low level representation by working with subclasses of diff --git a/docs/good-to-know.rst b/docs/good-to-know.rst index 4c468dbd..bbdfbc80 100644 --- a/docs/good-to-know.rst +++ b/docs/good-to-know.rst @@ -5,13 +5,15 @@ Here we describe some important concepts related to ELFI. These will help in und how to implement custom operations (such as simulators or summaries) and can potentially save the user from some pitfalls. -Generative model ----------------- -By a generative model we mean any model that can generate some data. In ELFI the -generative model is described with a `directed acyclic graph (DAG)`_ and the representation -is stored in the `ElfiModel`_ instance. It typically includes everything from the prior -distributions up to the summaries or distances. +ELFI model +---------- + +In ELFI, the priors, simulators, summaries, distances, etc. are called operations. ELFI +provides a convenient syntax of combining these operations into a network that is called +an `ElfiModel`_, where each node represents an operation. Basically, the `ElfiModel`_ is a +description of how different quantities needed in the inference are to be generated. The +structure of the network is a `directed acyclic graph (DAG)`_. .. _`directed acyclic graph (DAG)`: https://en.wikipedia.org/wiki/Directed_acyclic_graph @@ -22,7 +24,7 @@ Operations ---------- Operations are functions (or more generally Python callables) in the nodes of the -generative model. Those nodes that deal directly with data, e.g. priors, simulators, +ELFI model. Those nodes that deal directly with data, e.g. priors, simulators, summaries and distances should return a numpy array of length ``batch_size`` that contains their output. From fd8367aa6afd1e98c053fdd1e7c8d50b72a68bf9 Mon Sep 17 00:00:00 2001 From: Henri Vuollekoski Date: Mon, 3 Jul 2017 14:23:58 +0300 Subject: [PATCH 37/38] Release 0.6 (#200) --- CHANGELOG.rst | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 61e6b8d0..5b883728 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -2,8 +2,8 @@ Changelog ========== -dev branch (upcoming version) ------------------------------ +0.6 (2017-07-03) +---------------- - Changed some of the internal variable names in methods.py. Most notable outputs is now output_names. @@ -13,11 +13,12 @@ dev branch (upcoming version) - Result -> Sample - ResultSMC -> SmcSample - ResultBOLFI -> BolfiSample -- BO/BOLFI: take advantage of priors -- BO/BOLFI: take advantage of seed -- BO/BOLFI: improved optimization scheme -- BO/BOLFI: bounds must be a dict - +- Changes in BO/BOLFI: + - take advantage of priors + - take advantage of seed + - improved optimization scheme + - bounds must be a dict +- two new toy examples added: Gaussian and the Ricker model 0.5 (2017-05-19) ---------------- From 91918008979939682ece361af5da57d20a648ded Mon Sep 17 00:00:00 2001 From: Henri Vuollekoski Date: Mon, 3 Jul 2017 14:27:36 +0300 Subject: [PATCH 38/38] Update README.md --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index 2c15a7c1..6c327565 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,4 @@ -**Version 0.5 released!** This introduces many new features and small but significant changes in syntax. See the -CHANGELOG and [notebooks](https://github.com/elfi-dev/notebooks). +**Version 0.6 released!** See the CHANGELOG and [notebooks](https://github.com/elfi-dev/notebooks). ELFI - Engine for Likelihood-Free Inference ===========================================