diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 625ed2ef..17660926 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,5 +1,14 @@ Changelog -========== +========= + +0.6.2 (2017-09-06) +------------------ + +- Easier saving and loading of ElfiModel +- Renamed elfi.set_current_model to elfi.set_default_model +- Renamed elfi.get_current_model to elfi.get_default_model +- Improved performance when rerunning inference using stored data +- Change SMC to use ModelPrior, use to immediately reject invalid proposals 0.6.1 (2017-07-21) ------------------ diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index bd3c6ae5..4f9857a2 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -64,11 +64,11 @@ Ready to contribute? Here's how to set up `ELFI` for local development. $ git clone git@github.com:your_name_here/elfi.git -3. Install your local copy and the development requirements into a virtualenv. Assuming you have virtualenvwrapper installed, this is how you set up your fork for local development. Due to a bug in the pip installation of GPy numpy needs to be installed manually.:: +3. Install your local copy and the development requirements into a conda environment:: - $ mkvirtualenv elfi - $ cd elfi/ - $ pip install numpy + $ conda create -n elfi python=3.5 numpy + $ source activate elfi + $ cd elfi $ make dev 4. Create a branch for local development:: @@ -84,7 +84,7 @@ Ready to contribute? Here's how to set up `ELFI` for local development. $ make lint $ make test - Also make sure that the docstrings of your code are formatted properly:: + Also make sure that the docstrings of your code are formatted properly:: $ make docs @@ -99,26 +99,20 @@ Ready to contribute? Here's how to set up `ELFI` for local development. Style Guidelines ---------------- -The projects follows the `Khan Academy Style Guide `_. Except that we use numpy style docstrings instead of Google style docstrings. +The Python code in ELFI mostly follows `PEP8 `_, which is considered the de-facto code style guide for Python. Lines should not exceed 100 characters. -See `this example `_ for how to format the docstrings. +Docstrings follow the `NumPy style `_. -Additional Style Guidelines -~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -- Use the ``.format()`` string method instead of the old percent operator. For more information see `PyFormat `_. -- Use the type hinting syntax suggested `here `_ in the docstrings. - Pull Request Guidelines ----------------------- Before you submit a pull request, check that it meets these guidelines: -1. The pull request should include tests. +1. The pull request should include tests that will be run automatically using Travis-CI. 2. If the pull request adds functionality, the docs should be updated. Put your new functionality into a function with a docstring, and add the feature to the list in README.rst. -3. The pull request should work for Python 2.7, 3.5 and later. Check +3. The pull request should work for Python 3.5 and later. Check https://travis-ci.org/elfi-dev/elfi/pull_requests and make sure that the tests pass for all supported Python versions. diff --git a/Makefile b/Makefile index e86ba840..8d615921 100644 --- a/Makefile +++ b/Makefile @@ -69,7 +69,7 @@ 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.6.1/ +CONTENT_URL := http://research.cs.aalto.fi/pml/software/elfi/docs/0.6.2/ notebook-docs: ## Conver notebooks to rst docs. Assumes you have them in `notebooks` directory. jupyter nbconvert --to rst ../notebooks/quickstart.ipynb --output-dir docs diff --git a/README.md b/README.md index 3a461cc9..b1f82820 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -**Version 0.6.1 released!** See the CHANGELOG and [notebooks](https://github.com/elfi-dev/notebooks). +**Version 0.6.2 released!** See the CHANGELOG and [notebooks](https://github.com/elfi-dev/notebooks). ELFI - Engine for Likelihood-Free Inference =========================================== diff --git a/docs/api.rst b/docs/api.rst index 2e422203..7db39631 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -32,8 +32,9 @@ Below is the API for creating generative models. .. autosummary:: elfi.new_model - elfi.get_current_model - elfi.set_current_model + elfi.load_model + elfi.get_default_model + elfi.set_default_model .. currentmodule:: elfi.visualization.visualization diff --git a/docs/conf.py b/docs/conf.py index 49511b88..c092154d 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -13,9 +13,8 @@ # All configuration values have a default; values that are commented out # serve to show the default. -import sys import os - +import sys # http://docs.readthedocs.io/en/latest/faq.html#i-get-import-errors-on-libraries-that-depend-on-c-modules from unittest.mock import MagicMock @@ -23,15 +22,18 @@ class Mock(MagicMock): @classmethod def __getattr__(cls, name): - return MagicMock() + return MagicMock() + on_RTD = os.environ.get('READTHEDOCS', None) == 'True' if on_RTD: - 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', 'scipy.sparse', 'matplotlib.pyplot', 'numpy.random', 'networkx', - 'ipyparallel', 'numpy.lib', 'numpy.lib.format', 'sklearn.linear_model'] + 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', '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' @@ -39,7 +41,6 @@ def __getattr__(cls, name): else: html_theme = 'sphinx_rtd_theme' - # https://github.com/rtfd/readthedocs.org/issues/1139 """ def run_apidoc(_): @@ -179,7 +180,6 @@ def setup(app): # If true, `todo` and `todoList` produce output, else they produce nothing. todo_include_todos = False - # -- Options for HTML output ---------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for diff --git a/docs/quickstart.rst b/docs/quickstart.rst index 2efbe7a9..7ae33b29 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -7,7 +7,7 @@ First ensure you have Python 3.5 (or greater) and ELFI. After installation you can start using ELFI: -.. code:: python +.. code:: ipython3 import elfi @@ -15,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) @@ -30,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 @@ -48,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 @@ -73,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) @@ -89,7 +89,7 @@ model: If you have ``graphviz`` installed to your system, you can also visualize the model: -.. code:: python +.. code:: ipython3 # Plot the complete model (requires graphviz) elfi.draw(d) @@ -97,7 +97,7 @@ visualize the model: -.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.6.1/quickstart_files/quickstart_11_0.svg +.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.6.2/quickstart_files/quickstart_11_0.svg @@ -108,7 +108,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) @@ -127,7 +127,7 @@ posterior using threshold value 0.5: Let's plot also the marginal distributions for the parameters: -.. code:: python +.. code:: ipython3 import matplotlib.pyplot as plt res.plot_marginals() @@ -135,5 +135,5 @@ Let's plot also the marginal distributions for the parameters: -.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.6.1/quickstart_files/quickstart_16_0.png +.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.6.2/quickstart_files/quickstart_16_0.png diff --git a/docs/usage/BOLFI.rst b/docs/usage/BOLFI.rst index e00f756f..2cc7ca06 100644 --- a/docs/usage/BOLFI.rst +++ b/docs/usage/BOLFI.rst @@ -20,7 +20,7 @@ by several orders of magnitude. This tutorial demonstrates how to use BOLFI to do LFI in ELFI. -.. code:: python +.. code:: ipython3 import numpy as np import scipy.stats @@ -34,7 +34,7 @@ This tutorial demonstrates how to use BOLFI to do LFI in ELFI. logging.basicConfig(level=logging.INFO) # Set an arbitrary global seed to keep the randomly generated quantities the same - seed = 20170703 + seed = 1 np.random.seed(seed) import elfi @@ -43,7 +43,7 @@ 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:: python +.. code:: ipython3 from elfi.examples import ma2 model = ma2.get_model(seed_obs=seed) @@ -52,7 +52,7 @@ the basic tutorial, and load it from ready-made examples: -.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.6.1/usage/BOLFI_files/BOLFI_5_0.svg +.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.6.2/usage/BOLFI_files/BOLFI_5_0.svg @@ -67,7 +67,7 @@ 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:: python +.. code:: ipython3 log_d = elfi.Operation(np.log, model['d']) @@ -86,11 +86,13 @@ 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. +of noise added to the acquired points. Note that in general BOLFI does +not benefit from a ``batch_size`` higher than one, since the acquisition +surface is updated after each batch (especially so if the noise is 0!). -.. code:: python +.. code:: ipython3 - bolfi = elfi.BOLFI(log_d, batch_size=5, initial_evidence=20, update_interval=10, + bolfi = elfi.BOLFI(log_d, batch_size=1, 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 @@ -102,61 +104,21 @@ the relationship between parameter values and the resulting discrepancies. We'll request only 100 evidence points (including the ``initial_evidence`` defined above). -.. code:: python +.. code:: ipython3 - %time post = bolfi.fit(n_evidence=100) + %time post = bolfi.fit(n_evidence=200) .. parsed-literal:: INFO:elfi.methods.parameter_inference:BOLFI: Fitting the surrogate model... - INFO:elfi.methods.parameter_inference:Submitting batch 0 - INFO:elfi.methods.parameter_inference:Received batch 0 - INFO:elfi.methods.parameter_inference:Submitting batch 1 - INFO:elfi.methods.parameter_inference:Received batch 1 - INFO:elfi.methods.parameter_inference:Submitting batch 2 - INFO:elfi.methods.parameter_inference:Received batch 2 - INFO:elfi.methods.parameter_inference:Submitting batch 3 - INFO:elfi.methods.parameter_inference:Received batch 3 - INFO:elfi.methods.parameter_inference:Submitting batch 4 - INFO:elfi.methods.parameter_inference:Received batch 4 - INFO:elfi.methods.parameter_inference:Submitting batch 5 - INFO:elfi.methods.parameter_inference:Received batch 5 - INFO:elfi.methods.parameter_inference:Submitting batch 6 - INFO:elfi.methods.parameter_inference:Received batch 6 - INFO:elfi.methods.parameter_inference:Submitting batch 7 - INFO:elfi.methods.parameter_inference:Received batch 7 - INFO:elfi.methods.parameter_inference:Submitting batch 8 - INFO:elfi.methods.parameter_inference:Received batch 8 - INFO:elfi.methods.parameter_inference:Submitting batch 9 - INFO:elfi.methods.parameter_inference:Received batch 9 - INFO:elfi.methods.parameter_inference:Submitting batch 10 - INFO:elfi.methods.parameter_inference:Received batch 10 - INFO:elfi.methods.parameter_inference:Submitting batch 11 - INFO:elfi.methods.parameter_inference:Received batch 11 - INFO:elfi.methods.parameter_inference:Submitting batch 12 - INFO:elfi.methods.parameter_inference:Received batch 12 - INFO:elfi.methods.parameter_inference:Submitting batch 13 - INFO:elfi.methods.parameter_inference:Received batch 13 - INFO:elfi.methods.parameter_inference:Submitting batch 14 - INFO:elfi.methods.parameter_inference:Received batch 14 - INFO:elfi.methods.parameter_inference:Submitting batch 15 - INFO:elfi.methods.parameter_inference:Received batch 15 - INFO:elfi.methods.parameter_inference:Submitting batch 16 - INFO:elfi.methods.parameter_inference:Received batch 16 - INFO:elfi.methods.parameter_inference:Submitting batch 17 - INFO:elfi.methods.parameter_inference:Received batch 17 - INFO:elfi.methods.parameter_inference:Submitting batch 18 - INFO:elfi.methods.parameter_inference:Received batch 18 - INFO:elfi.methods.parameter_inference:Submitting batch 19 - INFO:elfi.methods.parameter_inference:Received batch 19 - INFO:elfi.methods.posteriors:Using optimized minimum value (-1.4121) of the GP discrepancy mean function as a threshold + INFO:elfi.methods.posteriors:Using optimized minimum value (-1.6146) of the GP discrepancy mean function as a threshold .. parsed-literal:: - CPU times: user 1min 25s, sys: 2.03 s, total: 1min 27s - Wall time: 12.2 s + CPU times: user 1min 48s, sys: 1.29 s, total: 1min 50s + Wall time: 1min (More on the returned ``BolfiPosterior`` object @@ -169,7 +131,7 @@ 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:: python +.. code:: ipython3 bolfi.target_model @@ -180,20 +142,20 @@ investigated further: Name : GP regression - Objective : 92.66483764086814 + Objective : 151.86636065302943 Number of Parameters : 4 Number of Optimization Parameters : 4 Updates : True Parameters: - GP_regression.  | value | constraints | priors - sum.rbf.variance  | 0.326569075885 | +ve | Ga(0.096, 1) - sum.rbf.lengthscale  | 0.552572835397 | +ve | Ga(1.3, 1) - sum.bias.variance  | 0.0878317673385 | +ve | Ga(0.024, 1) - Gaussian_noise.variance | 0.213186273967 | +ve | + GP_regression.  | value | constraints | priors + sum.rbf.variance  | 0.321697451372 | +ve | Ga(0.024, 1) + sum.rbf.lengthscale  | 0.541352150083 | +ve | Ga(1.3, 1) + sum.bias.variance  | 0.021827430988 | +ve | Ga(0.006, 1) + Gaussian_noise.variance | 0.183562040169 | +ve | -.. code:: python +.. code:: ipython3 bolfi.plot_state(); @@ -201,23 +163,23 @@ investigated further: .. parsed-literal:: - + -.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.6.1/usage/BOLFI_files/BOLFI_15_1.png +.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.6.2/usage/BOLFI_files/BOLFI_15_1.png It may be useful to see the acquired parameter values and the resulting discrepancies: -.. code:: python +.. code:: ipython3 bolfi.plot_discrepancy(); -.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.6.1/usage/BOLFI_files/BOLFI_17_0.png +.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.6.2/usage/BOLFI_files/BOLFI_17_0.png There could be an unnecessarily high number of points at parameter @@ -238,20 +200,20 @@ 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:: python +.. code:: ipython3 post2 = bolfi.extract_posterior(-1.) One can visualize a posterior directly (remember that the priors form a triangle): -.. code:: python +.. code:: ipython3 post.plot(logpdf=True) -.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.6.1/usage/BOLFI_files/BOLFI_23_0.png +.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.6.2/usage/BOLFI_files/BOLFI_23_0.png Sampling @@ -260,38 +222,38 @@ 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. +the GP approximation, the number of priors, their gradients etc., **this +may be slow**. -.. code:: python +.. 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.posteriors:Using optimized minimum value (-1.6146) 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.423. After warmup 79 proposals were outside of the region allowed by priors and rejected, decreasing acceptance ratio. + INFO:elfi.methods.mcmc:NUTS: Acceptance ratio: 0.423. After warmup 68 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.427. After warmup 80 proposals were outside of the region allowed by priors and rejected, decreasing acceptance ratio. + INFO:elfi.methods.mcmc:NUTS: Acceptance ratio: 0.422. After warmup 71 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.435. After warmup 74 proposals were outside of the region allowed by priors and rejected, decreasing acceptance ratio. + INFO:elfi.methods.mcmc:NUTS: Acceptance ratio: 0.419. After warmup 65 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. + INFO:elfi.methods.mcmc:NUTS: Acceptance ratio: 0.439. After warmup 66 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 1719.09995679 1.00101719174 - t2 1786.71042938 1.00178507347 - CPU times: user 3min 8s, sys: 2.71 s, total: 3min 11s - Wall time: 47.1 s + t1 2222.1197791 1.00106816947 + t2 2256.93599184 1.0003364409 + CPU times: user 1min 45s, sys: 1.29 s, total: 1min 47s + Wall time: 55.1 s The sampling algorithms may be fine-tuned with some parameters. The @@ -316,7 +278,7 @@ example of a difficult model for the NUTS algorithm. Now we finally have a ``Sample`` object again, which has several convenience methods: -.. code:: python +.. code:: ipython3 result_BOLFI @@ -327,29 +289,29 @@ convenience methods: Method: BOLFI Number of samples: 2000 - Number of simulations: 100 - Threshold: -1.41 - Sample means: t1: 0.577, t2: 0.27 + Number of simulations: 200 + Threshold: -1.61 + Sample means: t1: 0.429, t2: 0.0277 -.. code:: python +.. code:: ipython3 result_BOLFI.plot_traces(); -.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.6.1/usage/BOLFI_files/BOLFI_29_0.png +.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.6.2/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:: python +.. code:: ipython3 result_BOLFI.plot_marginals(); -.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.6.1/usage/BOLFI_files/BOLFI_31_0.png +.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.6.2/usage/BOLFI_files/BOLFI_31_0.png diff --git a/docs/usage/external.rst b/docs/usage/external.rst index 50e6378e..d6c545e2 100644 --- a/docs/usage/external.rst +++ b/docs/usage/external.rst @@ -283,7 +283,7 @@ information. That will be available under the ``meta`` keyword (see the -.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.6.1/usage/external_files/external_21_0.svg +.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.6.2/usage/external_files/external_21_0.svg @@ -344,7 +344,7 @@ run the inference for each of them: -.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.6.1/usage/external_files/external_25_0.svg +.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.6.2/usage/external_files/external_25_0.svg @@ -406,7 +406,7 @@ run the inference for each of them: -.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.6.1/usage/external_files/external_27_1.png +.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.6.2/usage/external_files/external_27_1.png Interfacing with R @@ -480,7 +480,7 @@ Load a ready made MA2 model: -.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.6.1/usage/external_files/external_36_0.svg +.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.6.2/usage/external_files/external_36_0.svg @@ -570,7 +570,7 @@ Load a ready made MA2 model: -.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.6.1/usage/external_files/external_48_0.svg +.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.6.2/usage/external_files/external_48_0.svg diff --git a/docs/usage/parallelization.rst b/docs/usage/parallelization.rst index 66f28e17..8e7faeaf 100644 --- a/docs/usage/parallelization.rst +++ b/docs/usage/parallelization.rst @@ -17,8 +17,8 @@ inference via different clients. Currently ELFI includes three clients: `ipyparallel `__ based client that can parallelize from multiple cores up to a distributed cluster. -A client is activated by importing the respective ELFI module or by -giving the name of the client to ``elfi.set_client``. +A client is activated by giving the name of the client to +``elfi.set_client``. This tutorial shows how to activate and use the ``multiprocessing`` or ``ipyparallel`` client with ELFI. The ``ipyparallel`` client supports @@ -27,14 +27,14 @@ local parallelization however, the ``multiprocessing`` client is simpler to use. Let's begin by importing ELFI and our example MA2 model from the tutorial. -.. code:: python +.. code:: ipython3 import elfi from elfi.examples import ma2 Let's get the model and plot it (requires graphviz) -.. code:: python +.. code:: ipython3 model = ma2.get_model() elfi.draw(model) @@ -42,7 +42,7 @@ Let's get the model and plot it (requires graphviz) -.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.6.1/usage/parallelization_files/parallelization_5_0.svg +.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.6.2/usage/parallelization_files/parallelization_5_0.svg @@ -52,7 +52,7 @@ Multiprocessing client The multiprocessing client allows you to easily use the cores available in your computer. You can activate it simply by -.. code:: python +.. code:: ipython3 elfi.set_client('multiprocessing') @@ -62,7 +62,7 @@ MA2 example model from the tutorial. When running the next command, take a look at the system monitor of your operating system; it should show that all of your cores are doing heavy computation simultaneously. -.. code:: python +.. code:: ipython3 rej = elfi.Rejection(model, 'd', batch_size=10000, seed=20170530) %time result = rej.sample(5000, n_sim=int(1e6)) # 1 million simulations @@ -76,7 +76,7 @@ that all of your cores are doing heavy computation simultaneously. And that is it. The result object is also just like in the basic case: -.. code:: python +.. code:: ipython3 # Print the summary result.summary() @@ -96,7 +96,7 @@ And that is it. The result object is also just like in the basic case: -.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.6.1/usage/parallelization_files/parallelization_11_1.png +.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.6.2/usage/parallelization_files/parallelization_11_1.png Ipyparallel client @@ -107,7 +107,7 @@ cluster environments. To use the ``ipyparallel`` client, you first have to create 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 @@ -125,7 +125,7 @@ Running parallel inference with ipyparallel After the cluster has been set up, we can proceed as usual. ELFI will take care of the parallelization from now on: -.. code:: python +.. code:: ipython3 # Let's start using the ipyparallel client elfi.set_client('ipyparallel') @@ -164,7 +164,7 @@ 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) @@ -174,7 +174,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 @@ -188,7 +188,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) @@ -198,7 +198,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 @@ -218,7 +218,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 @@ -243,7 +243,7 @@ engines. Remember to stop the ipcluster when done ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -.. code:: python +.. code:: ipython3 !ipcluster stop diff --git a/docs/usage/tutorial.rst b/docs/usage/tutorial.rst index 37c3a82e..444a97c5 100644 --- a/docs/usage/tutorial.rst +++ b/docs/usage/tutorial.rst @@ -12,7 +12,7 @@ save results for later use and run different inference algorithms. Let's begin by importing libraries that we will use and specify some settings. -.. code:: python +.. code:: ipython3 import time @@ -20,6 +20,8 @@ settings. import scipy.stats import matplotlib import matplotlib.pyplot as plt + import logging + logging.basicConfig(level=logging.INFO) %matplotlib inline %precision 2 @@ -52,7 +54,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 +100,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 +118,7 @@ observed data alone. -.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.6.1/usage/tutorial_files/tutorial_10_0.png +.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.6.2/usage/tutorial_files/tutorial_10_0.png Approximate Bayesian Computation @@ -165,7 +167,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 @@ -176,7 +178,7 @@ available in ``scipy.stats`` (for custom priors, see `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 +191,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 +208,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 +218,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 +227,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 +240,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.6.1/usage/tutorial_files/tutorial_27_0.svg +.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.6.2/usage/tutorial_files/tutorial_27_0.svg @@ -280,7 +282,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 +301,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 +310,12 @@ These indeed sample from a triangle: -.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.6.1/usage/tutorial_files/tutorial_33_0.png +.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.6.2/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 +325,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.6.1/usage/tutorial_files/tutorial_35_0.svg +.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.6.2/usage/tutorial_files/tutorial_35_0.svg @@ -351,7 +353,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 rej = elfi.Rejection(d, batch_size=10000, seed=seed) @@ -371,7 +373,7 @@ 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 = 1000 @@ -383,7 +385,7 @@ time is spent in drawing. -.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.6.1/usage/tutorial_files/tutorial_42_0.png +.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.6.2/usage/tutorial_files/tutorial_42_0.png @@ -394,8 +396,8 @@ time is spent in drawing. .. parsed-literal:: - CPU times: user 2.59 s, sys: 100 ms, total: 2.69 s - Wall time: 2.68 s + CPU times: user 2.28 s, sys: 165 ms, total: 2.45 s + Wall time: 2.45 s The ``sample`` method returns a ``Sample`` object, which contains @@ -406,7 +408,7 @@ 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() @@ -421,7 +423,7 @@ quantile. The ``Sample`` object includes a convenient ``summary`` method: -.. code:: python +.. code:: ipython3 result.summary() @@ -441,7 +443,7 @@ 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) @@ -450,8 +452,8 @@ as long as it takes to generate the requested number of samples. .. parsed-literal:: - CPU times: user 248 ms, sys: 12 ms, total: 260 ms - Wall time: 255 ms + CPU times: user 222 ms, sys: 40.3 ms, total: 263 ms + Wall time: 261 ms Method: Rejection Number of samples: 1000 Number of simulations: 40000 @@ -472,7 +474,7 @@ inference and then calls the ``iterate`` method. Below is an example how to run the inference until the objective has been reached or a maximum of one second of time has been used. -.. code:: python +.. code:: ipython3 # Request for 1M simulations. rej.set_objective(1000, n_sim=1000000) @@ -495,13 +497,13 @@ been reached or a maximum of one second of time has been used. Method: Rejection Number of samples: 1000 - Number of simulations: 150000 - Threshold: 0.0968 - Sample means: t1: 0.561, t2: 0.217 + Number of simulations: 190000 + Threshold: 0.0855 + Sample means: t1: 0.561, t2: 0.218 -.. code:: python +.. code:: ipython3 # We will see that it was not finished in 1 sec rej.finished @@ -534,7 +536,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) @@ -545,8 +547,8 @@ in our model: .. parsed-literal:: - CPU times: user 9.95 s, sys: 0 ns, total: 9.95 s - Wall time: 9.95 s + CPU times: user 5.26 s, sys: 37.1 ms, total: 5.3 s + Wall time: 5.3 s @@ -556,8 +558,8 @@ in our model: Method: Rejection Number of samples: 1000 Number of simulations: 1000000 - Threshold: 0.0362 - Sample means: t1: 0.554, t2: 0.233 + Threshold: 0.036 + Sample means: t1: 0.561, t2: 0.227 @@ -566,7 +568,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)) @@ -578,8 +580,8 @@ anything. Let's do that. .. parsed-literal:: - CPU times: user 700 ms, sys: 0 ns, total: 700 ms - Wall time: 699 ms + CPU times: user 636 ms, sys: 1.35 ms, total: 638 ms + Wall time: 638 ms @@ -589,8 +591,8 @@ anything. Let's do that. Method: Rejection Number of samples: 1000 Number of simulations: 1000000 - Threshold: 0.0453 - Sample means: t1: 0.555, t2: 0.235 + Threshold: 0.0452 + Sample means: t1: 0.56, t2: 0.228 @@ -600,7 +602,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 @@ -608,8 +610,8 @@ simulations and only have to simulate the new ones: .. parsed-literal:: - CPU times: user 2.07 s, sys: 16 ms, total: 2.09 s - Wall time: 2.09 s + CPU times: user 1.72 s, sys: 10.6 ms, total: 1.73 s + Wall time: 1.73 s @@ -619,8 +621,8 @@ simulations and only have to simulate the new ones: Method: Rejection Number of samples: 1000 Number of simulations: 1200000 - Threshold: 0.0421 - Sample means: t1: 0.554, t2: 0.239 + Threshold: 0.0417 + Sample means: t1: 0.561, t2: 0.225 @@ -629,7 +631,7 @@ lot of data to dictionaries, you will eventually run out of memory. ELFI provides an alternative pool that, by default, saves the outputs to standard numpy .npy files: -.. code:: python +.. code:: ipython3 arraypool = elfi.ArrayPool(['t1', 't2', 'Y', 'd']) rej = elfi.Rejection(d, pool=arraypool) @@ -638,14 +640,14 @@ standard numpy .npy files: .. parsed-literal:: - CPU times: user 40 ms, sys: 0 ns, total: 40 ms - Wall time: 37.1 ms + CPU times: user 25.8 ms, sys: 3.27 ms, total: 29 ms + Wall time: 28.5 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 save or close the pool) so that we can read the .npy files. arraypool.flush() @@ -656,12 +658,12 @@ This stores the simulated data in binary ``npy`` format under .. parsed-literal:: - Files in pools/arraypool_3615052699 are ['Y.npy', 't2.npy', 'd.npy', 't1.npy'] + Files in pools/arraypool_3521077242 are ['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') @@ -670,13 +672,13 @@ Now lets load all the parameters ``t1`` that were generated with numpy: .. parsed-literal:: - array([-0.82, -0.03, 0.27, ..., 1.03, 0.44, -0.56]) + array([ 0.79, -0.01, -1.47, ..., 0.98, 0.18, 0.5 ]) We can also close (or save) the whole pool if we wish to continue later: -.. code:: python +.. code:: ipython3 arraypool.close() name = arraypool.name @@ -685,13 +687,13 @@ We can also close (or save) the whole pool if we wish to continue later: .. parsed-literal:: - arraypool_3615052699 + arraypool_3521077242 And open it up later to continue where we were left. We can open it using its name: -.. code:: python +.. code:: ipython3 arraypool = elfi.ArrayPool.open(name) print('This pool has', len(arraypool), 'batches') @@ -707,7 +709,7 @@ using its name: You can delete the files with: -.. code:: python +.. code:: ipython3 arraypool.delete() @@ -733,24 +735,24 @@ are convenience methods to plotting functions defined under 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.6.1/usage/tutorial_files/tutorial_74_0.png +.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.6.2/usage/tutorial_files/tutorial_74_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.6.1/usage/tutorial_files/tutorial_76_0.png +.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.6.2/usage/tutorial_files/tutorial_76_0.png Note that if working in a non-interactive environment, you can use e.g. @@ -772,7 +774,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): @@ -813,7 +815,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) @@ -821,7 +823,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] @@ -830,13 +832,20 @@ population. In essence, a population is just refined rejection sampling. .. parsed-literal:: - CPU times: user 6.46 s, sys: 132 ms, total: 6.59 s - Wall time: 1.83 s + 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 1.72 s, sys: 154 ms, total: 1.87 s + Wall time: 1.56 s We can have summaries and plots of the results just like above: -.. code:: python +.. code:: ipython3 result_smc.summary(all=True) @@ -845,9 +854,9 @@ We can have summaries and plots of the results just like above: Method: SMC Number of samples: 1000 - Number of simulations: 190000 - Threshold: 0.0491 - Sample means: t1: 0.556, t2: 0.22 + Number of simulations: 170000 + Threshold: 0.0493 + Sample means: t1: 0.554, t2: 0.229 Population 0: Method: Rejection within SMC-ABC @@ -860,20 +869,20 @@ We can have summaries and plots of the results just like above: Method: Rejection within SMC-ABC Number of samples: 1000 Number of simulations: 20000 - Threshold: 0.185 - Sample means: t1: 0.556, t2: 0.236 + Threshold: 0.172 + Sample means: t1: 0.562, t2: 0.22 Population 2: Method: Rejection within SMC-ABC Number of samples: 1000 - Number of simulations: 160000 - Threshold: 0.0491 - Sample means: t1: 0.556, t2: 0.22 + Number of simulations: 140000 + Threshold: 0.0493 + Sample means: t1: 0.554, t2: 0.229 Or just the means: -.. code:: python +.. code:: ipython3 result_smc.sample_means_summary(all=True) @@ -881,32 +890,32 @@ Or just the means: .. parsed-literal:: Sample means for population 0: t1: 0.547, t2: 0.232 - Sample means for population 1: t1: 0.556, t2: 0.236 - Sample means for population 2: t1: 0.556, t2: 0.22 + Sample means for population 1: t1: 0.562, t2: 0.22 + Sample means for population 2: t1: 0.554, t2: 0.229 -.. code:: python +.. code:: ipython3 result_smc.plot_marginals(all=True, bins=25, figsize=(8, 2), fontsize=12) -.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.6.1/usage/tutorial_files/tutorial_89_0.png +.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.6.2/usage/tutorial_files/tutorial_89_0.png -.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.6.1/usage/tutorial_files/tutorial_89_1.png +.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.6.2/usage/tutorial_files/tutorial_89_1.png -.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.6.1/usage/tutorial_files/tutorial_89_2.png +.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.6.2/usage/tutorial_files/tutorial_89_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)) @@ -923,16 +932,14 @@ allows custom plotting: -.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.6.1/usage/tutorial_files/tutorial_91_0.png +.. image:: http://research.cs.aalto.fi/pml/software/elfi/docs/0.6.2/usage/tutorial_files/tutorial_91_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. However, the outliers carry zero weight and have no effect on -the estimates. +more around the true parameter values. Note, however, that samples from +SMC are weighed, and the weights should be accounted for when +interpreting the results. ELFI does this automatically when computing +the mean, for example. That's it! See the other documentation for more advanced topics on e.g. BOLFI, external simulators and parallelization. diff --git a/elfi/__init__.py b/elfi/__init__.py index cd5bdf29..ef2fa9f9 100644 --- a/elfi/__init__.py +++ b/elfi/__init__.py @@ -1,4 +1,10 @@ # -*- coding: utf-8 -*- +# flake8: noqa + +"""Engine for Likelihood-Free Inference (ELFI) is a statistical software +package for likelihood-free inference (LFI) such as Approximate Bayesian +Computation (ABC). +""" import elfi.clients.native @@ -17,4 +23,4 @@ __email__ = 'elfi-support@hiit.fi' # make sure __version_ is on the last non-empty line (read by setup.py) -__version__ = '0.6.1' +__version__ = '0.6.2' diff --git a/elfi/client.py b/elfi/client.py index e2ad4c3c..59ef04df 100644 --- a/elfi/client.py +++ b/elfi/client.py @@ -1,21 +1,21 @@ -import logging +"""This module contains the base client API and batch handler.""" + import importlib -from types import ModuleType +import logging from collections import OrderedDict +from types import ModuleType import networkx as nx +from elfi.compiler import (AdditionalNodesCompiler, ObservedCompiler, + OutputCompiler, RandomStateCompiler, ReduceCompiler) from elfi.executor import Executor -from elfi.compiler import OutputCompiler, ObservedCompiler, AdditionalNodesCompiler, \ - ReduceCompiler, RandomStateCompiler -from elfi.loader import ObservedLoader, AdditionalNodesLoader, RandomStateLoader, \ - PoolLoader +from elfi.loader import AdditionalNodesLoader, ObservedLoader, PoolLoader, RandomStateLoader logger = logging.getLogger(__name__) - -_client = None -_default_class = None +_client = None # a global for storing current client +_default_class = None # a global for storing default client class def get_client(): @@ -40,6 +40,7 @@ def set_client(client=None): def set_default_class(class_or_module): + """Set the default client class.""" global _default_class if isinstance(class_or_module, ModuleType): class_or_module = class_or_module.Client @@ -47,11 +48,19 @@ def set_default_class(class_or_module): class BatchHandler: - """ - Responsible for sending computational graphs to be executed in an Executor - """ + """Responsible for sending computational graphs to be executed in an Executor.""" def __init__(self, model, context, output_names=None, client=None): + """Compile the computational graph and associate it with a context etc. + + Parameters + ---------- + model : ElfiModel + context : ComputationContext + output_names : list of str, optional + client : Client, optional + + """ client = client or get_client() self.compiled_net = client.compile(model.source_net, output_names) @@ -62,7 +71,7 @@ def __init__(self, model, context, output_names=None, client=None): self._pending_batches = OrderedDict() def has_ready(self, any=False): - """Check if the next batch in succession is ready""" + """Check if the next batch in succession is ready.""" if len(self._pending_batches) == 0: return False @@ -75,37 +84,40 @@ def has_ready(self, any=False): @property def next_index(self): - """Returns the next batch index to be submitted""" + """Return the next batch index to be submitted.""" return self._next_batch_index @property def total(self): + """Return the total number of submitted batches.""" return self._next_batch_index @property def num_ready(self): + """Return the number of finished batches.""" return self.total - self.num_pending @property def num_pending(self): + """Return the total number of batches pending for evaluation.""" return len(self.pending_indices) @property def has_pending(self): + """Return whether any pending batches exist.""" return self.num_pending > 0 @property def pending_indices(self): + """Return the keys to pending batches.""" return self._pending_batches.keys() def cancel_pending(self): - """Cancels all the pending batches and sets the next batch_index to the index of - the last cancelled. + """Cancel all pending batches. - Note that we rely here on the assumption that batches are processed in order. + Sets the next batch_index to the index of the last cancelled. - Returns - ------- + Note that we rely here on the assumption that batches are processed in order. """ for batch_index, id in reversed(list(self._pending_batches.items())): @@ -118,22 +130,18 @@ def cancel_pending(self): self._next_batch_index = batch_index def reset(self): - """Cancels all the pending batches and sets the next index to 0 - """ + """Cancel all pending batches and set the next index to 0.""" self.cancel_pending() self._next_batch_index = 0 def submit(self, batch=None): - """Submits a batch with a batch index given by `next_index`. + """Submit a batch with a batch index given by `next_index`. Parameters ---------- batch : dict Overriding values for the batch. - Returns - ------- - """ batch = batch or {} batch_index = self._next_batch_index @@ -141,7 +149,8 @@ def submit(self, batch=None): logger.debug('Submitting batch {}'.format(batch_index)) loaded_net = self.client.load_data(self.compiled_net, self.context, batch_index) # Override - for k,v in batch.items(): loaded_net.node[k] = {'output': v} + for k, v in batch.items(): + loaded_net.node[k] = {'output': v} task_id = self.client.submit(loaded_net) self._pending_batches[batch_index] = task_id @@ -151,7 +160,7 @@ def submit(self, batch=None): self.context.num_submissions += 1 def wait_next(self): - """Waits for the next batch in succession""" + """Wait for the next batch in succession.""" if len(self._pending_batches) == 0: raise ValueError('Cannot wait for a batch, no batches currently submitted') @@ -169,14 +178,17 @@ def compute(self, batch_index=0): @property def num_cores(self): + """Return the number of processes.""" return self.client.num_cores class ClientBase: - """Client api for serving multiple simultaneous inferences""" + """Client api for serving multiple simultaneous inferences.""" def apply(self, kallable, *args, **kwargs): - """Non-blocking apply, returns immediately with an id for the task. + """Add `kallable(*args, **kwargs)` to the queue of tasks and return immediately. + + Non-blocking apply. Parameters ---------- @@ -186,43 +198,81 @@ def apply(self, kallable, *args, **kwargs): kwargs Keyword arguments for the kallable + Returns + ------- + id : int + Number of the queued task. + """ raise NotImplementedError def apply_sync(self, kallable, *args, **kwargs): - """Blocking apply, returns the result.""" + """Call and returns the result of `kallable(*args, **kwargs)`. + + Blocking apply. + + Parameters + ---------- + kallable : callable + + """ raise NotImplementedError def get_result(self, task_id): - """Get the result of the task. + """Return the result from task identified by `task_id` when it arrives. + + ELFI will call this only once per task_id. - ELFI will call this only once per task_id.""" + Parameters + ---------- + task_id : int + Id of the task whose result to return. + + """ raise NotImplementedError def is_ready(self, task_id): - """Queries whether task with id is completed""" + """Return whether task with identifier `task_id` is ready. + + Parameters + ---------- + task_id : int + + """ raise NotImplementedError def remove_task(self, task_id): + """Remove task with identifier `task_id` from pool. + + Parameters + ---------- + task_id : int + + """ raise NotImplementedError def reset(self): + """Stop all worker processes immediately and clear pending tasks.""" raise NotImplementedError def submit(self, loaded_net): + """Add `loaded_net` to the queue of tasks and return immediately.""" return self.apply(Executor.execute, loaded_net) def compute(self, loaded_net): + """Request evaluation of `loaded_net` and wait for result.""" return self.apply_sync(Executor.execute, loaded_net) @property def num_cores(self): + """Return the number of processes.""" raise NotImplementedError @classmethod def compile(cls, source_net, outputs=None): - """Compiles the structure of the output net. Does not insert any data - into the net. + """Compile the structure of the output net. + + Does not insert any data into the net. Parameters ---------- @@ -234,6 +284,7 @@ def compile(cls, source_net, outputs=None): ------- output_net : nx.DiGraph output_net codes the execution of the model + """ if outputs is None: outputs = source_net.nodes() @@ -241,8 +292,8 @@ def compile(cls, source_net, outputs=None): logger.warning("Compiling for no outputs!") outputs = outputs if isinstance(outputs, list) else [outputs] - compiled_net = nx.DiGraph(outputs=outputs, name=source_net.graph['name'], - observed=source_net.graph['observed']) + compiled_net = nx.DiGraph( + outputs=outputs, name=source_net.graph['name'], observed=source_net.graph['observed']) compiled_net = OutputCompiler.compile(source_net, compiled_net) compiled_net = ObservedCompiler.compile(source_net, compiled_net) @@ -254,7 +305,7 @@ def compile(cls, source_net, outputs=None): @classmethod def load_data(cls, compiled_net, context, batch_index): - """Loads data from the sources of the model and adds them to the compiled net. + """Load data from the sources of the model and adds them to the compiled net. Parameters ---------- @@ -265,8 +316,8 @@ def load_data(cls, compiled_net, context, batch_index): Returns ------- output_net : nx.DiGraph - """ + """ # Make a shallow copy of the graph loaded_net = nx.DiGraph(compiled_net) diff --git a/elfi/clients/__init__.py b/elfi/clients/__init__.py index e69de29b..6e031999 100644 --- a/elfi/clients/__init__.py +++ b/elfi/clients/__init__.py @@ -0,0 +1 @@ +# noqa: D104 diff --git a/elfi/clients/ipyparallel.py b/elfi/clients/ipyparallel.py index 37658142..5c229b45 100644 --- a/elfi/clients/ipyparallel.py +++ b/elfi/clients/ipyparallel.py @@ -1,23 +1,39 @@ -import logging +"""This module implements a multiprocessing client using ipyparallel. + +http://ipyparallel.readthedocs.io +""" + import itertools +import logging import ipyparallel as ipp -from elfi.executor import Executor import elfi.client logger = logging.getLogger(__name__) -# TODO: use import hook instead? https://docs.python.org/3/reference/import.html def set_as_default(): + """Set this as the default client.""" elfi.client.set_client() elfi.client.set_default_class(Client) class Client(elfi.client.ClientBase): + """A multiprocessing client using ipyparallel. + + http://ipyparallel.readthedocs.io + """ def __init__(self, ipp_client=None): + """Create an ipyparallel client for ELFI. + + Parameters + ---------- + ipp_client : ipyparallel.Client, optional + Use this ipyparallel client with ELFI. + + """ self.ipp_client = ipp_client or ipp.Client() self.view = self.ipp_client.load_balanced_view() @@ -25,35 +41,81 @@ def __init__(self, ipp_client=None): self._id_counter = itertools.count() def apply(self, kallable, *args, **kwargs): + """Add `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.view.apply(kallable, *args, **kwargs) self.tasks[id] = async_res return id def apply_sync(self, kallable, *args, **kwargs): + """Call and returns the result of `kallable(*args, **kwargs)`. + + Parameters + ---------- + kallable : callable + + """ return self.view.apply_sync(kallable, *args, **kwargs) def get_result(self, task_id): + """Return 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 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 + + """ async_result = self.tasks.pop(task_id) if not async_result.ready(): # Note: Ipyparallel is only able to abort if the job hasn't started. return self.ipp_client.abort(async_result, block=False) def reset(self): - # Note: Ipyparallel is only able to abort if the job hasn't started. + """Stop all worker processes immediately and clear pending tasks. + + Note: Ipyparallel is only able to abort if the job hasn't started. + """ self.view.abort(block=False) self.tasks.clear() @property def num_cores(self): + """Return the number of processes.""" return len(self.view) + # TODO: use import hook instead? https://docs.python.org/3/reference/import.html set_as_default() diff --git a/elfi/clients/multiprocessing.py b/elfi/clients/multiprocessing.py index ffb6c2c4..34152134 100644 --- a/elfi/clients/multiprocessing.py +++ b/elfi/clients/multiprocessing.py @@ -1,45 +1,49 @@ -import logging +"""This module implements a simple multiprocessing client.""" + import itertools +import logging import multiprocessing -from elfi.executor import Executor import elfi.client logger = logging.getLogger(__name__) def set_as_default(): + """Set this as the default client.""" 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(). - """ + """Client based on Python's built-in multiprocessing module.""" def __init__(self, num_processes=None): + """Create a multiprocessing client. + + Parameters + ---------- + num_processes : int, optional + Number of worker processes to use. Defaults to os.cpu_count(). + + """ 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. - + """Add `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) @@ -47,55 +51,60 @@ def apply(self, kallable, *args, **kwargs): return id def apply_sync(self, kallable, *args, **kwargs): - """Calls and returns the result of `kallable(*args, **kwargs)`. - + """Call and returns the result of `kallable(*args, **kwargs)`. + Parameters ---------- kallable : callable + """ return self.pool.apply(kallable, args, kwargs) def get_result(self, task_id): - """Returns the result from task identified by `task_id` when it arrives. - + """Return 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 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? + # TODO: also kill the pid? def reset(self): - """Stop all worker processes immediately and clear pending tasks. - """ + """Stop all worker processes immediately and clear pending tasks.""" self.pool.terminate() self.pool.join() self.tasks.clear() @property def num_cores(self): + """Return the number of processes.""" 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/elfi/clients/native.py b/elfi/clients/native.py index 5b57f7a2..f72323c8 100644 --- a/elfi/clients/native.py +++ b/elfi/clients/native.py @@ -1,51 +1,98 @@ -import logging -import itertools +"""This module implements the native single-core client.""" +import itertools +import logging -from elfi.executor import Executor import elfi.client logger = logging.getLogger(__name__) def set_as_default(): + """Set this as the default client.""" elfi.client.set_client() elfi.client.set_default_class(Client) class Client(elfi.client.ClientBase): - """ + """Simple non-parallel client. + Responsible for sending computational graphs to be executed in an Executor """ def __init__(self): + """Create a native client.""" self.tasks = {} self._ids = itertools.count() def apply(self, kallable, *args, **kwargs): + """Add `kallable(*args, **kwargs)` to the queue of tasks. Returns immediately. + + Parameters + ---------- + kallable : callable + + Returns + ------- + id : int + Number of the queued task. + + """ id = self._ids.__next__() self.tasks[id] = (kallable, args, kwargs) return id def apply_sync(self, kallable, *args, **kwargs): + """Call and returns the result of `kallable(*args, **kwargs)`. + + Parameters + ---------- + kallable : callable + + """ return kallable(*args, **kwargs) def get_result(self, task_id): + """Return the result from task identified by `task_id` when it arrives. + + Parameters + ---------- + task_id : int + Id of the task whose result to return. + + """ kallable, args, kwargs = self.tasks.pop(task_id) return kallable(*args, **kwargs) def is_ready(self, task_id): + """Return whether task with identifier `task_id` is ready. + + Parameters + ---------- + task_id : int + + """ return True 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] def reset(self): + """Stop all worker processes immediately and clear pending tasks.""" self.tasks.clear() @property def num_cores(self): + """Return the number of processes, which is always 1 for the native client.""" return 1 + set_as_default() diff --git a/elfi/compiler.py b/elfi/compiler.py index 49c3dbd9..78d80820 100644 --- a/elfi/compiler.py +++ b/elfi/compiler.py @@ -1,17 +1,20 @@ +"""Compilation augments the ElfiModel with nodes and flags that are required for execution.""" + import logging import networkx as nx from elfi.utils import args_to_tuple, nbunch_ancestors, observed_name - logger = logging.getLogger(__name__) class Compiler: + """Base class for Compilers.""" + @classmethod def compile(cls, source_net, compiled_net): - """ + """Compiles the nodes present in the `source_net`. Parameters ---------- @@ -26,10 +29,23 @@ def compile(cls, source_net, compiled_net): raise NotImplementedError -class OutputCompiler(Compiler): +class OutputCompiler(Compiler): # noqa: D101 @classmethod def compile(cls, source_net, compiled_net): - """Compiles the nodes present in the source_net + """Flag nodes for running. + + Augments the state dictionaries of each node with a flag + that determines whether the node is runnable or not. + + Parameters + ---------- + source_net : nx.DiGraph + compiled_net : nx.DiGraph + + Returns + ------- + compiled_net : nx.Digraph + """ logger.debug("{} compiling...".format(cls.__name__)) @@ -55,10 +71,20 @@ def compile(cls, source_net, compiled_net): return compiled_net -class ObservedCompiler(Compiler): +class ObservedCompiler(Compiler): # noqa: D101 @classmethod def compile(cls, source_net, compiled_net): - """Adds observed nodes to the computation graph + """Add observed nodes to the computation graph. + + Parameters + ---------- + source_net : nx.DiGraph + compiled_net : nx.DiGraph + + Returns + ------- + compiled_net : nx.Digraph + """ logger.debug("{} compiling...".format(cls.__name__)) @@ -87,8 +113,7 @@ def compile(cls, source_net, compiled_net): else: link_parent = parent - compiled_net.add_edge(link_parent, obs_node, - source_net[parent][node].copy()) + compiled_net.add_edge(link_parent, obs_node, source_net[parent][node].copy()) # Check that there are no stochastic nodes in the ancestors for node in uses_observed: @@ -104,6 +129,19 @@ def compile(cls, source_net, compiled_net): @classmethod def make_observed_copy(cls, node, compiled_net, operation=None): + """Make a renamed copy of an observed node and add it to `compiled_net`. + + Parameters + ---------- + node : str + compiled_net : nx.DiGraph + operation : callable, optional + + Returns + ------- + str + + """ obs_node = observed_name(node) if compiled_net.has_node(obs_node): @@ -118,13 +156,24 @@ def make_observed_copy(cls, node, compiled_net, operation=None): return obs_node -class AdditionalNodesCompiler(Compiler): +class AdditionalNodesCompiler(Compiler): # noqa: D101 @classmethod def compile(cls, source_net, compiled_net): + """Add runtime instruction nodes to the computation graph. + + Parameters + ---------- + source_net : nx.DiGraph + compiled_net : nx.DiGraph + + Returns + ------- + compiled_net : nx.Digraph + + """ logger.debug("{} compiling...".format(cls.__name__)) - instruction_node_map = dict(_uses_batch_size='_batch_size', - _uses_meta='_meta') + instruction_node_map = dict(_uses_batch_size='_batch_size', _uses_meta='_meta') for instruction, _node in instruction_node_map.items(): for node, d in source_net.nodes_iter(data=True): @@ -136,9 +185,21 @@ def compile(cls, source_net, compiled_net): return compiled_net -class RandomStateCompiler(Compiler): +class RandomStateCompiler(Compiler): # noqa: D101 @classmethod def compile(cls, source_net, compiled_net): + """Add a node for random state and edges to stochastic nodes in the computation graph. + + Parameters + ---------- + source_net : nx.DiGraph + compiled_net : nx.DiGraph + + Returns + ------- + compiled_net : nx.Digraph + + """ logger.debug("{} compiling...".format(cls.__name__)) _random_node = '_random_state' @@ -150,9 +211,21 @@ def compile(cls, source_net, compiled_net): return compiled_net -class ReduceCompiler(Compiler): +class ReduceCompiler(Compiler): # noqa: D101 @classmethod def compile(cls, source_net, compiled_net): + """Remove redundant nodes from the computation graph. + + Parameters + ---------- + source_net : nx.DiGraph + compiled_net : nx.DiGraph + + Returns + ------- + compiled_net : nx.Digraph + + """ logger.debug("{} compiling...".format(cls.__name__)) outputs = compiled_net.graph['outputs'] diff --git a/elfi/examples/__init__.py b/elfi/examples/__init__.py index 977acbdd..6e031999 100644 --- a/elfi/examples/__init__.py +++ b/elfi/examples/__init__.py @@ -1,6 +1 @@ -# -*- coding: utf-8 -*- - -from elfi.examples import ma2 -from elfi.examples import bdm -from elfi.examples import gauss -from elfi.examples import ricker +# noqa: D104 diff --git a/elfi/examples/bdm.py b/elfi/examples/bdm.py index 0d0c0966..572e565f 100644 --- a/elfi/examples/bdm.py +++ b/elfi/examples/bdm.py @@ -1,10 +1,3 @@ -import os -import warnings - -import numpy as np -import elfi - - """The model used in Lintusaari et al. 2016 with summary statistic T1. References @@ -15,9 +8,16 @@ """ +import os +import warnings + +import numpy as np + +import elfi + def prepare_inputs(*inputs, **kwinputs): - """Function to prepare the inputs for the simulator. + """Prepare the inputs for the simulator. The signature follows that given in `elfi.tools.external_operation`. This function appends kwinputs with unique and descriptive filenames and writes an input file for @@ -43,7 +43,7 @@ def prepare_inputs(*inputs, **kwinputs): def process_result(completed_process, *inputs, **kwinputs): - """Function to process the result of the BDM simulation. + """Process the result of the BDM simulation. The signature follows that given in `elfi.tools.external_operation`. """ @@ -71,28 +71,28 @@ def process_result(completed_process, *inputs, **kwinputs): def T1(clusters): """Summary statistic for BDM.""" clusters = np.atleast_2d(clusters) - return np.sum(clusters > 0, 1)/np.sum(clusters, 1) + return np.sum(clusters > 0, 1) / np.sum(clusters, 1) def T2(clusters, n=20): """Another summary statistic for BDM.""" clusters = np.atleast_2d(clusters) - return 1 - np.sum((clusters/n)**2, axis=1) + return 1 - np.sum((clusters / n)**2, axis=1) def get_sources_path(): + """Return the path to the C++ source code.""" return os.path.join(os.path.dirname(os.path.realpath(__file__)), 'cpp') def get_model(alpha=0.2, delta=0, tau=0.198, N=20, seed_obs=None): - """Returns the example model used in Lintusaari et al. 2016. + """Return the example model used in Lintusaari et al. 2016. Here we infer alpha using the summary statistic T1. We expect the executable `bdm` be available in the working directory. Parameters ---------- - alpha : float birth rate delta : float @@ -108,8 +108,8 @@ def get_model(alpha=0.2, delta=0, tau=0.198, N=20, seed_obs=None): Returns ------- m : elfi.ElfiModel - """ + """ if seed_obs is None and N == 20: y = np.zeros(N, dtype='int16') data = np.array([6, 3, 2, 2, 1, 1, 1, 1, 1, 1, 1], dtype='int16') @@ -136,4 +136,3 @@ def get_model(alpha=0.2, delta=0, tau=0.198, N=20, seed_obs=None): "and compile the source.".format(cpp_path), RuntimeWarning) return m - diff --git a/elfi/examples/bignk.py b/elfi/examples/bignk.py new file mode 100644 index 00000000..7c1db509 --- /dev/null +++ b/elfi/examples/bignk.py @@ -0,0 +1,165 @@ +"""An example implementation of the univariate g-and-k model.""" + +from functools import partial + +import numpy as np +import scipy.stats as ss + +import elfi +from elfi.examples.gnk import euclidean_multidim, ss_octile, ss_order, ss_robust + +EPS = np.finfo(float).eps + + +def BiGNK(a1, a2, b1, b2, g1, g2, k1, k2, rho, c=.8, n_obs=150, batch_size=1, + random_state=None): + """Sample the bi g-and-k distribution. + + References + ---------- + [1] Drovandi, Christopher C., and Anthony N. Pettitt. "Likelihood-free + Bayesian estimation of multivariate quantile distributions." + Computational Statistics & Data Analysis 55.9 (2011): 2541-2556. + [2] Allingham, David, R. AR King, and Kerrie L. Mengersen. "Bayesian + estimation of quantile distributions."Statistics and Computing 19.2 + (2009): 189-201. + + Parameters + ---------- + a1 : float or array_like + The location (the 1st dimension). + a2 : float or array_like + The location (the 2nd dimension). + b1 : float or array_like + The scale (the 1st dimension). + b2 : float or array_like + The scale (the 2nd dimension). + g1 : float or array_like + The skewness (the 1st dimension). + g2 : float or array_like + The skewness (the 2nd dimension). + k1 : float or array_like + The kurtosis (the 1st dimension). + k2 : float or array_like + The kurtosis (the 2nd dimension). + rho : float or array_like + The dependence between components (dimensions), [1]. + c : float, optional + The overall asymmetry parameter, as a convention fixed to 0.8 [2]. + n_obs : int, optional + The number of the observed points + batch_size : int, optional + random_state : np.random.RandomState, optional + + Returns + ------- + array_like + The yielded points. + + """ + # Standardising the parameters + a1 = np.asanyarray(a1).reshape((-1, 1)) + a2 = np.asanyarray(a2).reshape((-1, 1)) + b1 = np.asanyarray(b1).reshape((-1, 1)) + b2 = np.asanyarray(b2).reshape((-1, 1)) + g1 = np.asanyarray(g1).reshape((-1, 1)) + g2 = np.asanyarray(g2).reshape((-1, 1)) + k1 = np.asanyarray(k1).reshape((-1, 1, 1)) + k2 = np.asanyarray(k2).reshape((-1, 1, 1)) + rho = np.asanyarray(rho).reshape((-1, 1)) + a = np.hstack((a1, a2)) + b = np.hstack((b1, b2)) + g = np.hstack((g1, g2)) + k = np.hstack((k1, k2)) + + # Sampling from the z term, Equation 3 [1]. + z = [] + for i in range(batch_size): + matrix_cov = np.array([[1, rho[i]], [rho[i], 1]]) + z_el = ss.multivariate_normal.rvs(cov=matrix_cov, + size=(n_obs), + random_state=random_state) + z.append(z_el) + z = np.array(z) + + # Obtaining the first bracket term, Equation 3 [1]. + gdotz = np.einsum('ik,ijk->ijk', g, z) + term_exp = (1 - np.exp(-gdotz)) / (1 + np.exp(-gdotz)) + term_first = np.einsum('ik,ijk->ijk', b, (1 + c * (term_exp))) + + # Obtaining the second bracket term, Equation 3 [1]. + term_second_unraised = 1 + np.power(z, 2) + k = np.repeat(k, n_obs, axis=2) + k = np.swapaxes(k, 1, 2) + term_second = np.power(term_second_unraised, k) + + # Yielding Equation 3, [1]. + term_product = term_first * term_second * z + term_product_misaligned = np.swapaxes(term_product, 1, 0) + y_misaligned = np.add(a, term_product_misaligned) + y = np.swapaxes(y_misaligned, 1, 0) + # print(y.shape) + return y + + +def get_model(n_obs=150, true_params=None, stats_summary=None, seed_obs=None): + """Return an initialised bivariate g-and-k model. + + Parameters + ---------- + n_obs : int, optional + The number of the observed points. + true_params : array_like, optional + The parameters defining the model. + stats_summary : array_like, optional + The chosen summary statistics, expressed as a list of strings. + Options: ['ss_order'], ['ss_robust'], ['ss_octile']. + seed_obs : np.random.RandomState, optional + + Returns + ------- + elfi.ElfiModel + + """ + m = elfi.ElfiModel() + + # Initialising the default parameter settings as given in [1]. + if true_params is None: + true_params = [3, 4, 1, 0.5, 1, 2, .5, .4, 0.6] + if stats_summary is None: + stats_summary = ['ss_robust'] + + # Initialising the default prior settings as given in [1]. + elfi.Prior('uniform', 0, 5, model=m, name='a1') + elfi.Prior('uniform', 0, 5, model=m, name='a2') + elfi.Prior('uniform', 0, 5, model=m, name='b1') + elfi.Prior('uniform', 0, 5, model=m, name='b2') + elfi.Prior('uniform', -5, 10, model=m, name='g1') + elfi.Prior('uniform', -5, 10, model=m, name='g2') + elfi.Prior('uniform', -.5, 5.5, model=m, name='k1') + elfi.Prior('uniform', -.5, 5.5, model=m, name='k2') + elfi.Prior('uniform', -1 + EPS, 2 - 2 * EPS, model=m, name='rho') + + # Generating the observations. + y_obs = BiGNK(*true_params, n_obs=n_obs, + random_state=np.random.RandomState(seed_obs)) + + # Defining the simulator. + fn_sim = partial(BiGNK, n_obs=n_obs) + elfi.Simulator(fn_sim, m['a1'], m['a2'], m['b1'], m['b2'], m['g1'], + m['g2'], m['k1'], m['k2'], m['rho'], observed=y_obs, + name='BiGNK') + + # Initialising the chosen summary statistics. + fns_summary_all = [ss_order, ss_robust, ss_octile] + fns_summary_chosen = [] + for fn_summary in fns_summary_all: + if fn_summary.__name__ in stats_summary: + summary = elfi.Summary(fn_summary, m['BiGNK'], + name=fn_summary.__name__) + fns_summary_chosen.append(summary) + + # Defining the distance metric. + elfi.Discrepancy(euclidean_multidim, *fns_summary_chosen, name='d') + + return m diff --git a/elfi/examples/gauss.py b/elfi/examples/gauss.py index 4e29d6b3..2e2b8091 100644 --- a/elfi/examples/gauss.py +++ b/elfi/examples/gauss.py @@ -1,59 +1,66 @@ +"""An example implementation of a Gaussian noise model.""" + +from functools import partial + 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=50, batch_size=1, random_state=None): + """Sample the Gaussian distribution. + Parameters + ---------- + mu : float, array_like + sigma : float, array_like + n_obs : int, optional + batch_size : int, optional + random_state : RandomState, optional -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)) - y = ss.norm.rvs(loc=mu, scale=sigma, size=(batch_size, n_obs), - random_state=random_state) + 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. - """ + """Return 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. - """ + """Return the summary statistic corresponding to the variance.""" ss = np.var(x, axis=1) return ss def get_model(n_obs=50, true_params=None, seed_obs=None): - """Returns a complete Gaussian noise model + """Return a complete Gaussian noise model. Parameters ---------- - n_obs : int + n_obs : int, optional the number of observations - true_params : list + true_params : list, optional true_params[0] corresponds to the mean, true_params[1] corresponds to the standard deviation - seed_obs : None, int + seed_obs : int, optional 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)) + 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() diff --git a/elfi/examples/gnk.py b/elfi/examples/gnk.py new file mode 100644 index 00000000..eeabb4df --- /dev/null +++ b/elfi/examples/gnk.py @@ -0,0 +1,254 @@ +"""An example implementation of the bivariate g-and-k model.""" + +from functools import partial + +import numpy as np +import scipy.stats as ss + +import elfi + +EPS = np.finfo(float).eps + + +def GNK(a, b, g, k, c=0.8, n_obs=50, batch_size=1, random_state=None): + """Sample the univariate g-and-k distribution. + + References + ---------- + [1] Drovandi, Christopher C., and Anthony N. Pettitt. "Likelihood-free + Bayesian estimation of multivariate quantile distributions." + Computational Statistics & Data Analysis 55.9 (2011): 2541-2556. + [2] Allingham, David, R. AR King, and Kerrie L. Mengersen. "Bayesian + estimation of quantile distributions."Statistics and Computing 19.2 + (2009): 189-201. + + Parameters + ---------- + a : float or array_like + The location. + b : float or array_like + The scale. + g : float or array_like + The skewness. + k : float or array_like + The kurtosis. + c : float, optional + The overall asymmetry parameter, as a convention fixed to 0.8 [2]. + n_obs : int, optional + The number of the observed points + batch_size : int, optional + random_state : np.random.RandomState, optional + + Returns + ------- + array_like + The yielded points. + + """ + # Standardising the parameters + a = np.asanyarray(a).reshape((-1, 1)) + b = np.asanyarray(b).reshape((-1, 1)) + g = np.asanyarray(g).reshape((-1, 1)) + k = np.asanyarray(k).reshape((-1, 1)) + + # Sampling from the z term, Equation 1, [2]. + z = ss.norm.rvs(size=(batch_size, n_obs), random_state=random_state) + + # Yielding Equation 1, [2]. + term_exp = (1 - np.exp(-g * z)) / (1 + np.exp(-g * z)) + y = a + b * (1 + c * (term_exp)) * (1 + z**2)**k * z + + # Dedicating an axis for the data dimensionality. + y = np.expand_dims(y, axis=2) + return y + + +def get_model(n_obs=50, true_params=None, stats_summary=None, seed_obs=None): + """Return an initialised univariate g-and-k model. + + Parameters + ---------- + n_obs : int, optional + The number of the observed points. + true_params : array_like, optional + The parameters defining the model. + stats_summary : array_like, optional + The chosen summary statistics, expressed as a list of strings. + Options: ['ss_order'], ['ss_robust'], ['ss_octile']. + seed_obs : np.random.RandomState, optional + + Returns + ------- + elfi.ElfiModel + + """ + m = elfi.ElfiModel() + + # Initialising the default parameter settings as given in [2]. + if true_params is None: + true_params = [3, 1, 2, .5] + if stats_summary is None: + stats_summary = ['ss_order'] + + # Initialising the default prior settings as given in [2]. + elfi.Prior('uniform', 0, 10, model=m, name='a') + elfi.Prior('uniform', 0, 10, model=m, name='b') + elfi.Prior('uniform', 0, 10, model=m, name='g') + elfi.Prior('uniform', 0, 10, model=m, name='k') + + # Generating the observations. + y_obs = GNK(*true_params, n_obs=n_obs, + random_state=np.random.RandomState(seed_obs)) + + # Defining the simulator. + fn_sim = partial(GNK, n_obs=n_obs) + elfi.Simulator(fn_sim, m['a'], m['b'], m['g'], m['k'], observed=y_obs, + name='GNK') + + # Initialising the chosen summary statistics. + fns_summary_all = [ss_order, ss_robust, ss_octile] + fns_summary_chosen = [] + for fn_summary in fns_summary_all: + if fn_summary.__name__ in stats_summary: + summary = elfi.Summary(fn_summary, m['GNK'], + name=fn_summary.__name__) + fns_summary_chosen.append(summary) + + elfi.Discrepancy(euclidean_multidim, *fns_summary_chosen, name='d') + + return m + + +def euclidean_multidim(*simulated, observed): + """Calculate the multi-dimensional Euclidean distance. + + Parameters + ---------- + *simulated: array_like + The simulated points. + observed : array_like + The observed points. + + Returns + ------- + array_like + + """ + pts_sim = np.column_stack(simulated) + pts_obs = np.column_stack(observed) + d_multidim = np.sum((pts_sim - pts_obs)**2., axis=1) + d_squared = np.sum(d_multidim, axis=1) + d = np.sqrt(d_squared) + + return d + + +def ss_order(y): + """Obtain the order summary statistic, [2]. + + The statistic reaches an optimal performance upon a low number of + observations. + + Parameters + ---------- + y : array_like + The yielded points. + + Returns + ------- + array_like + + """ + ss_order = np.sort(y) + + return ss_order + + +def ss_robust(y): + """Obtain the robust summary statistic, [1]. + + The statistic reaches an optimal performance upon a high number of + observations. + + Parameters + ---------- + y : array_like + The yielded points. + + Returns + ------- + array_like + + """ + ss_a = _get_ss_a(y) + ss_b = _get_ss_b(y) + ss_g = _get_ss_g(y) + ss_k = _get_ss_k(y) + + ss_robust = np.stack((ss_a, ss_b, ss_g, ss_k), axis=1) + + return ss_robust + + +def ss_octile(y): + """Obtain the octile summary statistic. + + The statistic reaches an optimal performance upon a high number of + observations. As reported in [1], it is more stable than ss_robust. + + Parameters + ---------- + y : array_like + The yielded points. + + Returns + ------- + array_like + + """ + octiles = np.linspace(12.5, 87.5, 7) + E1, E2, E3, E4, E5, E6, E7 = np.percentile(y, octiles, axis=1) + + ss_octile = np.stack((E1, E2, E3, E4, E5, E6, E7), axis=1) + + return ss_octile + + +def _get_ss_a(y): + L2 = np.percentile(y, 50, axis=1) + ss_a = L2 + + return ss_a + + +def _get_ss_b(y): + L1, L3 = np.percentile(y, [25, 75], axis=1) + ss_b = L3 - L1 + + # Adjusting the zero values to avoid division issues. + ss_b_ravelled = ss_b.ravel() + idxs_zero = np.where(ss_b_ravelled == 0)[0] + ss_b_ravelled[idxs_zero] += EPS + n_dim = y.shape[-1] + n_batches = y.shape[0] + ss_b = ss_b_ravelled.reshape(n_batches, n_dim) + + return ss_b + + +def _get_ss_g(y): + L1, L2, L3 = np.percentile(y, [25, 50, 75], axis=1) + + ss_b = _get_ss_b(y) + ss_g = np.divide(L3 + L1 - 2 * L2, ss_b) + + return ss_g + + +def _get_ss_k(y): + E1, E3, E5, E7 = np.percentile(y, [12.5, 37.5, 62.5, 87.5], axis=1) + + ss_b = _get_ss_b(y) + ss_k = np.divide(E7 - E5 + E3 - E1, ss_b) + + return ss_k diff --git a/elfi/examples/ma2.py b/elfi/examples/ma2.py index ec9182c7..afdb43da 100644 --- a/elfi/examples/ma2.py +++ b/elfi/examples/ma2.py @@ -1,5 +1,6 @@ +"""Example implementation of the 2nd order Moving Average (MA2) model.""" + from functools import partial -import warnings import numpy as np import scipy.stats as ss @@ -7,24 +8,39 @@ import elfi -"""Example implementation of the MA2 model -""" +def MA2(t1, t2, n_obs=100, batch_size=1, random_state=None): + r"""Generate a sequence of samples from the MA2 model. + The sequence is a moving average -def MA2(t1, t2, n_obs=100, batch_size=1, random_state=None): + x_i = w_i + \theta_1 w_{i-1} + \theta_2 w_{i-2} + + where w_i are white noise ~ N(0,1). + + Parameters + ---------- + t1 : float, array_like + t2 : float, array_like + n_obs : int, optional + batch_size : int, optional + random_state : RandomState, optional + + """ # Make inputs 2d arrays for broadcasting with w t1 = np.asanyarray(t1).reshape((-1, 1)) t2 = np.asanyarray(t2).reshape((-1, 1)) random_state = random_state or np.random # i.i.d. sequence ~ N(0,1) - w = random_state.randn(batch_size, n_obs+2) - x = w[:, 2:] + t1*w[:, 1:-1] + t2*w[:, :-2] + w = random_state.randn(batch_size, n_obs + 2) + x = w[:, 2:] + t1 * w[:, 1:-1] + t2 * w[:, :-2] return x def autocov(x, lag=1): - """Autocovariance assuming a (weak) univariate stationary process with mean 0. + """Return the autocovariance. + + Assumes a (weak) univariate stationary process with mean 0. Realizations are in rows. Parameters @@ -35,28 +51,30 @@ def autocov(x, lag=1): Returns ------- C : np.array of size (n,) + """ x = np.atleast_2d(x) # In R this is normalized with x.shape[1] - C = np.mean(x[:, lag:]*x[:, :-lag], axis=1) + C = np.mean(x[:, lag:] * x[:, :-lag], axis=1) return C def get_model(n_obs=100, true_params=None, seed_obs=None): - """Returns a complete MA2 model in inference task + """Return a complete MA2 model in inference task. Parameters ---------- - n_obs : int + n_obs : int, optional observation length of the MA2 process - true_params : list + true_params : list, optional parameters with which the observed data is generated - seed_obs : None, int + seed_obs : int, optional seed for the observed data generation Returns ------- m : elfi.ElfiModel + """ if true_params is None: true_params = [.6, .2] @@ -76,38 +94,72 @@ 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(elfi.Distribution): + """Define prior for t1 in range [-a, a]. + + As in Marin et al., 2012. + """ + @classmethod def rvs(cls, b, size=1, random_state=None): + """Get random variates. + + Parameters + ---------- + b : float + size : int or tuple, optional + random_state : RandomState, optional + + Returns + ------- + arraylike + + """ u = ss.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) + t1 = np.where(u < 0.5, np.sqrt(2. * u) * b - b, -np.sqrt(2. * (1. - u)) * b + b) return t1 @classmethod def pdf(cls, x, b): - p = 1./b - np.abs(x) / (b*b) + """Return density at `x`. + + Parameters + ---------- + x : float or arraylike + b : float + + Returns + ------- + arraylike + + """ + p = 1. / b - np.abs(x) / (b * b) # set values outside of [-b, b] to zero p = np.where(p < 0., 0., p) return p -# Define prior t2 conditionally on t1 as in Marin et al., 2012, in range [-a, a] class CustomPrior2(elfi.Distribution): + """Define prior for t2 conditionally on t1 in range [-a, a]. + + As in Marin et al., 2012. + """ + @classmethod def rvs(cls, t1, a, size=1, random_state=None): - """ + """Get random variates. Parameters ---------- - t1 : float - a : float - size : int or tuple - random_state : None, RandomState + t1 : float or arraylike + a : float + size : int or tuple, optional + random_state : RandomState, optional Returns ------- + arraylike """ - locs = np.maximum(-a - t1, -a + t1) scales = a - locs t2 = ss.uniform.rvs(loc=locs, scale=scales, size=size, random_state=random_state) @@ -115,7 +167,20 @@ def rvs(cls, t1, a, size=1, random_state=None): @classmethod def pdf(cls, x, t1, a): + """Return density at `x`. + + Parameters + ---------- + x : float or arraylike + t1 : float or arraylike + a : float + + Returns + ------- + arraylike + + """ locs = np.maximum(-a - t1, -a + t1) scales = a - locs - p = (x >= locs) * (x <= locs + scales) * 1/np.where(scales>0, scales, 1) + p = (x >= locs) * (x <= locs + scales) * 1 / np.where(scales > 0, scales, 1) return p diff --git a/elfi/examples/ricker.py b/elfi/examples/ricker.py index df2918df..5b09e12d 100644 --- a/elfi/examples/ricker.py +++ b/elfi/examples/ricker.py @@ -1,18 +1,19 @@ +"""Example implementation of the Ricker model.""" + from functools import partial + import numpy as np import scipy.stats as ss -import elfi +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. - + """Generate samples from 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 @@ -26,6 +27,7 @@ def ricker(log_rate, stock_init=1., n_obs=50, batch_size=1, random_state=None): Returns ------- stock : np.array + """ random_state = random_state or np.random @@ -33,14 +35,22 @@ def ricker(log_rate, stock_init=1., n_obs=50, batch_size=1, random_state=None): stock[:, 0] = stock_init for ii in range(1, n_obs): - stock[:, ii] = stock[:, ii-1] * np.exp(log_rate - stock[:, ii-1]) + 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). - +def stochastic_ricker(log_rate, + std, + scale, + stock_init=1., + n_obs=50, + batch_size=1, + random_state=None): + """Generate samples from the stochastic Ricker model. + + Here the observed stock ~ Poisson(true stock * scaling). + Parameters ---------- log_rate : float or np.array @@ -58,6 +68,7 @@ def stochastic_ricker(log_rate, std, scale, stock_init=1., n_obs=50, batch_size= Returns ------- stock_obs : np.array + """ random_state = random_state or np.random @@ -75,12 +86,12 @@ def stochastic_ricker(log_rate, std, scale, stock_init=1., n_obs=50, batch_size= def get_model(n_obs=50, true_params=None, seed_obs=None, stochastic=True): - """Returns a complete Ricker model in inference task. - + """Return 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, + + Wood, S. N. (2010) Statistical inference for noisy nonlinear ecological dynamic systems, Nature 466, 1102–1107. Parameters @@ -89,7 +100,7 @@ def get_model(n_obs=50, true_params=None, seed_obs=None, stochastic=True): Number of observations. true_params : list, optional Parameters with which the observed data is generated. - seed_obs : None, int, optional + seed_obs : int, optional Seed for the observed data generation. stochastic : bool, optional Whether to use the stochastic or deterministic Ricker model. @@ -97,8 +108,8 @@ def get_model(n_obs=50, true_params=None, seed_obs=None, stochastic=True): Returns ------- m : elfi.ElfiModel - """ + """ if stochastic: simulator = partial(stochastic_ricker, n_obs=n_obs) if true_params is None: @@ -134,13 +145,15 @@ def get_model(n_obs=50, true_params=None, seed_obs=None, stochastic=True): def chi_squared(*simulated, observed): - """Chi squared goodness of fit. - Adjusts for differences in magnitude between dimensions. - + """Return 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) @@ -149,7 +162,6 @@ def chi_squared(*simulated, observed): def num_zeros(x): - """A summary statistic: number of zero observations. - """ + """Return a summary statistic: number of zero observations.""" n = np.sum(x == 0, axis=1) return n diff --git a/elfi/executor.py b/elfi/executor.py index a1edbe38..52716c0b 100644 --- a/elfi/executor.py +++ b/elfi/executor.py @@ -1,3 +1,5 @@ +"""This module includes the Executor of ELFI graphs.""" + import logging from operator import itemgetter @@ -7,8 +9,7 @@ class Executor: - """ - Responsible for computing the graph G + """Responsible for computing the graph G. The format of the computable graph G is `nx.DiGraph`. The execution order of the nodes is fixed and follows the topological ordering of G. The following properties are @@ -42,7 +43,7 @@ class Executor: @classmethod def execute(cls, G): - """ + """Execute a graph. Parameters ---------- @@ -53,7 +54,6 @@ def execute(cls, G): dict of node outputs """ - order = cls.get_execution_order(G) for node in order: @@ -77,13 +77,14 @@ def execute(cls, G): '{}'.format(node)) # Make a result dict based on the requested outputs - result = {k:G.node[k]['output'] for k in G.graph['outputs']} + result = {k: G.node[k]['output'] for k in G.graph['outputs']} return result - @classmethod def get_execution_order(cls, G): - """This method returns the minimal list of nodes that need to be executed in + """Return a list of nodes to execute. + + This method returns the minimal list of nodes that need to be executed in graph G in order to return the requested outputs. The ordering of the nodes is fixed. @@ -96,6 +97,7 @@ def get_execution_order(cls, G): ------- nodes : list nodes that require execution + """ nodes = set() order = nx_constant_topological_sort(G) @@ -119,7 +121,6 @@ def get_execution_order(cls, G): return [n for n in order if n in nodes] - @staticmethod def _run(fn, node, G): args = [] @@ -140,8 +141,9 @@ def _run(fn, node, G): def nx_constant_topological_sort(G, nbunch=None, reverse=False): - """Return a list of nodes in a constant topological sort order. This implementations is - adapted from `networkx.topological_sort`. + """Return a list of nodes in a constant topological sort order. + + This implementations is adapted from `networkx.topological_sort`. Modified version of networkx.topological_sort. The difference is that this version will always return the same order for the same graph G given that the nodes @@ -183,10 +185,10 @@ def nx_constant_topological_sort(G, nbunch=None, reverse=False): ---------- .. [1] Skiena, S. S. The Algorithm Design Manual (Springer-Verlag, 1998). http://www.amazon.com/exec/obidos/ASIN/0387948600/ref=ase_thealgorithmrepo/ + """ if not G.is_directed(): - raise nx.NetworkXError( - "Topological sort not defined on undirected graphs.") + raise nx.NetworkXError("Topological sort not defined on undirected graphs.") # nonrecursive version seen = set() @@ -196,16 +198,16 @@ def nx_constant_topological_sort(G, nbunch=None, reverse=False): if nbunch is None: # Sort them to alphabetical order nbunch = sorted(G.nodes()) - for v in nbunch: # process all vertices in G + for v in nbunch: # process all vertices in G if v in explored: continue - fringe = [v] # nodes yet to look at + fringe = [v] # nodes yet to look at while fringe: w = fringe[-1] # depth first search if w in explored: # already looked down this branch fringe.pop() continue - seen.add(w) # mark as seen + seen.add(w) # mark as seen # Check successors for cycles and for new nodes new_nodes = [] for n in sorted(G[w]): @@ -213,12 +215,12 @@ def nx_constant_topological_sort(G, nbunch=None, reverse=False): if n in seen: # CYCLE !! raise nx.NetworkXUnfeasible("Graph contains a cycle.") new_nodes.append(n) - if new_nodes: # Add new_nodes to fringe + if new_nodes: # Add new_nodes to fringe fringe.extend(new_nodes) - else: # No new nodes so w is fully explored + else: # No new nodes so w is fully explored explored.add(w) order.append(w) - fringe.pop() # done considering this node + fringe.pop() # done considering this node if reverse: return order else: diff --git a/elfi/loader.py b/elfi/loader.py index f2ea859c..364cd0ae 100644 --- a/elfi/loader.py +++ b/elfi/loader.py @@ -1,15 +1,16 @@ +"""Loading makes precomputed data accessible to nodes.""" + import numpy as np -from elfi.utils import observed_name, get_sub_seed, is_array +from elfi.utils import get_sub_seed, observed_name class Loader: - """ - Loads precomputed values to the compiled network - """ + """Base class for Loaders.""" + @classmethod def load(cls, context, compiled_net, batch_index): - """Load data into nodes of compiled_net + """Load precomputed data into nodes of `compiled_net`. Parameters ---------- @@ -22,16 +23,29 @@ def load(cls, context, compiled_net, batch_index): net : nx.DiGraph Loaded net, which is the `compiled_net` that has been loaded with data that can depend on the batch_index. - """ + """ + raise NotImplementedError -class ObservedLoader(Loader): - """ - Add the observed data to the compiled net - """ +class ObservedLoader(Loader): # noqa: D101 @classmethod def load(cls, context, compiled_net, batch_index): + """Add the observed data to the `compiled_net`. + + Parameters + ---------- + context : ComputationContext + compiled_net : nx.DiGraph + batch_index : int + + Returns + ------- + net : nx.DiGraph + Loaded net, which is the `compiled_net` that has been loaded with data that + can depend on the batch_index. + + """ observed = compiled_net.graph['observed'] for name, obs in observed.items(): @@ -44,17 +58,32 @@ def load(cls, context, compiled_net, batch_index): return compiled_net -class AdditionalNodesLoader(Loader): +class AdditionalNodesLoader(Loader): # noqa: D101 @classmethod def load(cls, context, compiled_net, batch_index): - meta_dict = {'batch_index': batch_index, - 'submission_index': context.num_submissions, - 'master_seed': context.seed, - 'model_name': compiled_net.graph['name'] - } + """Add runtime information to instruction nodes. + + Parameters + ---------- + context : ComputationContext + compiled_net : nx.DiGraph + batch_index : int + + Returns + ------- + net : nx.DiGraph + Loaded net, which is the `compiled_net` that has been loaded with data that + can depend on the batch_index. + + """ + meta_dict = { + 'batch_index': batch_index, + 'submission_index': context.num_submissions, + 'master_seed': context.seed, + 'model_name': compiled_net.graph['name'] + } - details = dict(_batch_size=context.batch_size, - _meta=meta_dict) + details = dict(_batch_size=context.batch_size, _meta=meta_dict) for node, v in details.items(): if compiled_net.has_node(node): @@ -63,10 +92,24 @@ def load(cls, context, compiled_net, batch_index): return compiled_net -class PoolLoader(Loader): - +class PoolLoader(Loader): # noqa: D101 @classmethod def load(cls, context, compiled_net, batch_index): + """Add data from the pools in `context`. + + Parameters + ---------- + context : ComputationContext + compiled_net : nx.DiGraph + batch_index : int + + Returns + ------- + net : nx.DiGraph + Loaded net, which is the `compiled_net` that has been loaded with data that + can depend on the batch_index. + + """ if context.pool is None: return compiled_net @@ -89,35 +132,48 @@ def load(cls, context, compiled_net, batch_index): # We use a getter function so that the local process np.random doesn't get # copied to the loaded_net. def get_np_random(): + """Get RandomState.""" return np.random.mtrand._rand -class RandomStateLoader(Loader): - """ - Add random state instance for the node - """ - +class RandomStateLoader(Loader): # noqa: D101 @classmethod def load(cls, context, compiled_net, batch_index): + """Add an instance of random state to the corresponding node. + + Parameters + ---------- + context : ComputationContext + compiled_net : nx.DiGraph + batch_index : int + + Returns + ------- + net : nx.DiGraph + Loaded net, which is the `compiled_net` that has been loaded with data that + can depend on the batch_index. + + """ key = 'output' seed = context.seed + 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.int32, np.uint32)): - random_state = np.random.RandomState(context.seed) + # TODO: In the future, we could use https://pypi.python.org/pypi/randomstate to enable + # jumps? + sub_seed, context.sub_seed_cache = get_sub_seed(seed, + batch_index, + cache=context.sub_seed_cache) + random_state = np.random.RandomState(sub_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 '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' - if compiled_net.has_node(_random_node): - compiled_net.node[_random_node][key] = random_state + # Assign the random state or its acquirer function to the corresponding node + node_name = '_random_state' + if compiled_net.has_node(node_name): + compiled_net.node[node_name][key] = random_state return compiled_net diff --git a/elfi/methods/__init__.py b/elfi/methods/__init__.py index e69de29b..6e031999 100644 --- a/elfi/methods/__init__.py +++ b/elfi/methods/__init__.py @@ -0,0 +1 @@ +# noqa: D104 diff --git a/elfi/methods/bo/__init__.py b/elfi/methods/bo/__init__.py index e69de29b..6e031999 100644 --- a/elfi/methods/bo/__init__.py +++ b/elfi/methods/bo/__init__.py @@ -0,0 +1 @@ +# noqa: D104 diff --git a/elfi/methods/bo/acquisition.py b/elfi/methods/bo/acquisition.py index 96366b58..3a29db62 100644 --- a/elfi/methods/bo/acquisition.py +++ b/elfi/methods/bo/acquisition.py @@ -1,24 +1,32 @@ +"""Implementations for acquiring locations of new evidence for Bayesian optimization.""" + import logging import numpy as np -from scipy.stats import uniform, truncnorm +from scipy.stats import truncnorm, uniform from elfi.methods.bo.utils import minimize - logger = logging.getLogger(__name__) class AcquisitionBase: """All acquisition functions are assumed to fulfill this interface. - + 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. """ - def __init__(self, model, prior=None, n_inits=10, max_opt_iters=1000, noise_var=None, - exploration_rate=10, seed=None): - """ + + def __init__(self, + model, + prior=None, + n_inits=10, + max_opt_iters=1000, + noise_var=None, + exploration_rate=10, + seed=None): + """Initialize AcquisitionBase. Parameters ---------- @@ -27,7 +35,7 @@ def __init__(self, model, prior=None, n_inits=10, max_opt_iters=1000, noise_var= bounds : tuple of length 'input_dim' of tuples (min, max) and methods evaluate(x) : function that returns model (mean, var, std) - prior + prior : scipy-like distribution, optional By default uniform distribution within model bounds. n_inits : int, optional Number of initialization points in internal optimization. @@ -42,8 +50,8 @@ def __init__(self, model, prior=None, n_inits=10, max_opt_iters=1000, noise_var= seed : int, optional Seed for getting consistent acquisition results. Used in getting random starting locations in acquisition function optimization. - """ + """ self.model = model self.prior = prior self.n_inits = int(n_inits) @@ -57,29 +65,31 @@ def __init__(self, model, prior=None, n_inits=10, max_opt_iters=1000, noise_var= 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'. + """Evaluate the acquisition function at 'x'. Parameters ---------- x : numpy.array t : int current iteration (starting from 0) + """ raise NotImplementedError def evaluate_gradient(self, x, t=None): - """Evaluates the gradient of acquisition function value at 'x'. + """Evaluate the gradient of acquisition function at 'x'. Parameters ---------- x : numpy.array t : int Current iteration (starting from 0). + """ raise NotImplementedError def acquire(self, n, t=None): - """Returns the next batch of acquisition points. + """Return the next batch of acquisition points. Gaussian noise ~N(0, self.noise_var) is added to the acquired points. @@ -89,20 +99,30 @@ def acquire(self, n, t=None): Number of acquisition points to return. t : int Current acq_batch_index (starting from 0). - random_state : np.random.RandomState, optional Returns ------- x : np.ndarray - The shape is (n_values, input_dim) + The shape is (n, input_dim) + """ 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) - xhat, _ = minimize(obj, self.model.bounds, grad_obj, self.prior, self.n_inits, - self.max_opt_iters, random_state=self.random_state) + def obj(x): + return self.evaluate(x, t) + + def grad_obj(x): + return self.evaluate_gradient(x, t) + + 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)) @@ -125,14 +145,16 @@ def _add_noise(self, x): 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) + 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. + r"""Lower Confidence Bound Selection Criterion. + + Srinivas et al. call this GP-LCB. LCBSC uses the parameter delta which is here equivalent to 1/exploration_rate. @@ -148,20 +170,21 @@ class LCBSC(AcquisitionBase): N. Srinivas, A. Krause, S. M. Kakade, and M. Seeger. Gaussian process optimization in the bandit setting: No regret and experimental design. In Proc. International Conference on Machine Learning (ICML), 2010 - + E. Brochu, V.M. Cora, and N. de Freitas. A tutorial on Bayesian optimization of expensive cost functions, with application to active user modeling and hierarchical reinforcement learning. arXiv:1012.2599, 2010. - + Notes ----- The formula presented in Brochu (pp. 15) seems to be from Srinivas et al. Theorem 2. - However, instead of having t**(d/2 + 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). + """ - + def __init__(self, *args, delta=None, **kwargs): - """ + """Initialize LCBSC. Parameters ---------- @@ -170,46 +193,50 @@ def __init__(self, *args, delta=None, **kwargs): 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 + kwargs['exploration_rate'] = 1 / delta super(LCBSC, self).__init__(*args, **kwargs) @property def delta(self): - return 1/self.exploration_rate + """Return the inverse of exploration rate.""" + return 1 / self.exploration_rate 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)) + 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: - + r"""Evaluate the Lower confidence bound selection criterion. + mean - sqrt(\beta_t) * std - + Parameters ---------- x : numpy.array t : int Current iteration (starting from 0). + """ mean, var = self.model.predict(x, noiseless=True) return mean - np.sqrt(self._beta(t) * var) def evaluate_gradient(self, x, t=None): - """Gradient of the lower confidence bound selection criterion. - + """Evaluate the 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) @@ -218,8 +245,24 @@ def evaluate_gradient(self, x, t=None): class UniformAcquisition(AcquisitionBase): + """Acquisition from uniform distribution.""" def acquire(self, n, t=None): + """Return random points from uniform distribution. + + Parameters + ---------- + n : int + Number of acquisition points to return. + t : int, optional + (unused) + + Returns + ------- + x : np.ndarray + The shape is (n, input_dim) + + """ bounds = np.stack(self.model.bounds) - return uniform(bounds[:,0], bounds[:,1] - bounds[:,0])\ + return uniform(bounds[:, 0], bounds[:, 1] - bounds[:, 0]) \ .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 c94b0740..19b0a5fe 100644 --- a/elfi/methods/bo/gpy_regression.py +++ b/elfi/methods/bo/gpy_regression.py @@ -1,10 +1,12 @@ +"""This module contains an interface for using the GPy library in ELFI.""" + # TODO: make own general GPRegression and kernel classes -import logging import copy +import logging -import numpy as np import GPy +import numpy as np logger = logging.getLogger(__name__) logging.getLogger("GP").setLevel(logging.WARNING) # GPy library logger @@ -14,33 +16,38 @@ class GPyRegression: """Gaussian Process regression using the GPy library. GPy API: https://sheffieldml.github.io/GPy/ - - Parameters - ---------- - - 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 - mean_function - """ - def __init__(self, parameter_names=None, bounds=None, optimizer="scg", max_opt_iters=50, - gp=None, **gp_params): + def __init__(self, + parameter_names=None, + bounds=None, + optimizer="scg", + max_opt_iters=50, + gp=None, + **gp_params): + """Initialize GPyRegression. + Parameters + ---------- + 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 + mean_function + + """ if parameter_names is None: input_dim = 1 elif isinstance(parameter_names, (list, tuple)): @@ -52,8 +59,9 @@ def __init__(self, parameter_names=None, bounds=None, optimizer="scg", max_opt_i 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(len(bounds), input_dim)) + raise ValueError( + 'Length of `bounds` ({}) does not match the length of `parameter_names` ({}).' + .format(len(bounds), input_dim)) elif isinstance(bounds, dict): if len(bounds) == 1: # might be the case parameter_names=None @@ -79,13 +87,15 @@ def __init__(self, parameter_names=None, bounds=None, optimizer="scg", max_opt_i self.is_sampling = False # set to True once in sampling phase def __str__(self): + """Return GPy's __str__.""" return self._gp.__str__() def __repr__(self): + """Return GPy's __str__.""" return self.__str__() def predict(self, x, noiseless=False): - """Returns the GP model mean and variance at x. + """Return the GP model mean and variance at x. Parameters ---------- @@ -103,15 +113,15 @@ def predict(self, x, noiseless=False): with shape (x.shape[0], 1) var : np.array with shape (x.shape[0], 1) - """ + """ # 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)), \ - np.ones((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: @@ -149,7 +159,19 @@ def _cache_RBF_kernel(self): self._rbf_is_cached = True def predict_mean(self, x): - """Returns the GP model mean function at x. + """Return the GP model mean function at x. + + Parameters + ---------- + x : np.array + numpy compatible (n, input_dim) array of points to evaluate + if len(x.shape) == 1 will be cast to 2D with x[None, :] + + Returns + ------- + np.array + with shape (x.shape[0], 1) + """ return self.predict(x)[0] @@ -170,15 +192,15 @@ def predictive_gradients(self, x): with shape (x.shape[0], input_dim) grad_var : np.array with shape (x.shape[0], input_dim) - """ + """ # 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)), \ - 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: @@ -195,12 +217,24 @@ def predictive_gradients(self, x): 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) + grad_mu = grad_mu[:, :, 0] # Assume 1D output (distance in ABC) return grad_mu, grad_var def predictive_gradient_mean(self, x): """Return the gradient of the GP model mean at x. + + Parameters + ---------- + x : np.array + numpy compatible (n, input_dim) array of points to evaluate + if len(x.shape) == 1 will be cast to 2D with x[None, :] + + Returns + ------- + np.array + with shape (x.shape[0], input_dim) + """ return self.predictive_gradients(x)[0] @@ -210,7 +244,8 @@ def _init_gp(self, x, y): if self.gp_params.get('kernel') is None: kernel = self._default_kernel(x, y) - if self.gp_params.get('noise_var') is None and self.gp_params.get('mean_function') is None: + if self.gp_params.get('noise_var') is None and self.gp_params.get( + 'mean_function') is None: self._kernel_is_default = True else: @@ -218,8 +253,8 @@ def _init_gp(self, x, y): noise_var = self.gp_params.get('noise_var') or np.max(y)**2. / 100. mean_function = self.gp_params.get('mean_function') - self._gp = self._make_gpy_instance(x, y, kernel=kernel, noise_var=noise_var, - mean_function=mean_function) + self._gp = self._make_gpy_instance( + x, y, kernel=kernel, noise_var=noise_var, mean_function=mean_function) def _default_kernel(self, x, y): # Some heuristics to choose kernel parameters based on the initial data @@ -233,8 +268,7 @@ def _default_kernel(self, x, y): # 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) + 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: @@ -245,13 +279,20 @@ def _default_kernel(self, x, y): return kernel def _make_gpy_instance(self, x, y, kernel, noise_var, mean_function): - return GPy.models.GPRegression(X=x, Y=y, kernel=kernel, noise_var=noise_var, - mean_function=mean_function) + return GPy.models.GPRegression( + X=x, Y=y, kernel=kernel, noise_var=noise_var, mean_function=mean_function) def update(self, x, y, optimize=False): - """Updates the GP model with new data - """ + """Update the GP model with new data. + + Parameters + ---------- + x : np.array + y : np.array + optimize : bool, optional + Whether to optimize hyperparameters. + """ # Must cast these as 2d for GPy x = x.reshape((-1, self.input_dim)) y = y.reshape((-1, 1)) @@ -266,16 +307,15 @@ def update(self, x, y, optimize=False): 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.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) + self._gp = self._make_gpy_instance( + x, y, kernel=kernel, noise_var=noise_var, mean_function=mean_function) if optimize: self.optimize() def optimize(self): - """Optimize GP hyper parameters. - """ - logger.debug("Optimizing GP hyper parameters") + """Optimize GP hyperparameters.""" + logger.debug("Optimizing GP hyperparameters") try: self._gp.optimize(self.optimizer, max_iters=self.max_opt_iters) except np.linalg.linalg.LinAlgError: @@ -283,23 +323,23 @@ def optimize(self): @property def n_evidence(self): - """Returns the number of observed samples. - """ + """Return the number of observed samples.""" if self._gp is None: return 0 return self._gp.num_data @property def X(self): - """Return input evidence""" + """Return input evidence.""" return self._gp.X @property def Y(self): - """Return output evidence""" + """Return output evidence.""" return self._gp.Y def copy(self): + """Return a copy of current instance.""" kopy = copy.copy(self) if self._gp: kopy._gp = self._gp.copy() @@ -313,4 +353,5 @@ def copy(self): return kopy def __copy__(self): + """Return a copy of current instance.""" return self.copy() diff --git a/elfi/methods/bo/utils.py b/elfi/methods/bo/utils.py index 1237f66e..3c207ea0 100644 --- a/elfi/methods/bo/utils.py +++ b/elfi/methods/bo/utils.py @@ -1,19 +1,47 @@ +"""Utilities for Bayesian optimization.""" + import numpy as np from scipy.optimize import differential_evolution, fmin_l_bfgs_b # 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, - polish=polish, init='latinhypercube', seed=seed) + """Find the minimum of function 'fun' in 'maxiter' iterations. + + Parameters + ---------- + fun : callable + Function to minimize. + bounds : list of tuples + Bounds for each parameter. + maxiter : int, optional + Maximum number of iterations. + polish : bool, optional + Whether to "polish" the result. + seed : int, optional + + See scipy.optimize.differential_evolution. + + Returns + ------- + tuple of the found coordinates of minimum and the corresponding value. + + """ + result = differential_evolution( + func=fun, bounds=bounds, maxiter=maxiter, polish=polish, init='latinhypercube', seed=seed) return result.x, result.fun # TODO: allow argument for specifying the optimization algorithm -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'. - +def minimize(fun, + bounds, + grad=None, + prior=None, + n_start_points=10, + maxiter=1000, + random_state=None): + """Find the minimum of function 'fun'. + Parameters ---------- fun : callable @@ -30,10 +58,11 @@ def minimize(fun, bounds, grad=None, prior=None, n_start_points=10, maxiter=1000 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. + """ ndim = len(bounds) start_points = np.empty((n_start_points, ndim)) @@ -58,13 +87,14 @@ def minimize(fun, bounds, grad=None, prior=None, n_start_points=10, maxiter=1000 # Run optimization from each initialization point for i in range(n_start_points): if grad is not None: - result = fmin_l_bfgs_b(fun, start_points[i, :], fprime=grad, bounds=bounds, maxiter=maxiter) + 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) + result = fmin_l_bfgs_b( + fun, start_points[i, :], approx_grad=True, bounds=bounds, maxiter=maxiter) locs.append(result[0]) vals[i] = result[1] # Return the optimal case ind_min = np.argmin(vals) return locs[ind_min], vals[ind_min] - diff --git a/elfi/methods/mcmc.py b/elfi/methods/mcmc.py index 9c4c9054..7540d1de 100644 --- a/elfi/methods/mcmc.py +++ b/elfi/methods/mcmc.py @@ -1,15 +1,17 @@ +"""MCMC sampling methods.""" + import logging import numpy as np - logger = logging.getLogger(__name__) -# TODO: combine ESS and Rhat?, consider transforming parameters to allowed region to increase acceptance ratio +# TODO: combine ESS and Rhat?, consider transforming parameters to allowed +# region to increase acceptance ratio def eff_sample_size(chains): - """Calculates the effective sample size for 1 or more chains. + """Calculate the effective sample size for 1 or more chains. See: @@ -25,13 +27,14 @@ def eff_sample_size(chains): Returns ------- ess : float + """ chains = np.atleast_2d(chains) n_chains, n_samples = chains.shape means = np.mean(chains, axis=1) variances = np.var(chains, ddof=1, axis=1) - var_between = 0 if n_chains==1 else n_samples * np.var(means, ddof=1) + var_between = 0 if n_chains == 1 else n_samples * np.var(means, ddof=1) var_within = np.mean(variances) var_pooled = ((n_samples - 1.) * var_within + var_between) / n_samples @@ -61,8 +64,10 @@ def eff_sample_size(chains): def gelman_rubin(chains): - """Calculates the Gelman--Rubin convergence statistic, also known as the - potential scale reduction factor, or \hat{R}. Uses the split version, as in Stan. + r"""Calculate the Gelman--Rubin convergence statistic. + + Also known as the potential scale reduction factor, or \hat{R}. + Uses the split version, as in Stan. See: @@ -82,6 +87,7 @@ def gelman_rubin(chains): ------- psrf : float Should be below 1.1 to support convergence, or at least below 1.2 for all parameters. + """ chains = np.atleast_2d(chains) n_chains, n_samples = chains.shape @@ -89,7 +95,7 @@ def gelman_rubin(chains): # split chains in the middle n_chains *= 2 n_samples //= 2 # drop 1 if odd - chains = chains[:, :2*n_samples].reshape((n_chains, n_samples)) + chains = chains[:, :2 * n_samples].reshape((n_chains, n_samples)) means = np.mean(chains, axis=1) variances = np.var(chains, ddof=1, axis=1) @@ -105,9 +111,20 @@ def gelman_rubin(chains): return psrf -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=20, stepsize=None): - """No-U-Turn Sampler, an improved version of the Hamiltonian (Markov Chain) Monte Carlo sampler. +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=20, + stepsize=None): + r"""Sample the target using the NUTS algorithm. + + No-U-Turn Sampler, an improved version of the Hamiltonian (Markov Chain) Monte Carlo sampler. Based on Algorithm 6 in Hoffman & Gelman, depthMLR 15, 1351-1381, 2014. @@ -141,8 +158,8 @@ def nuts(n_iter, params0, target, grad_target, n_adapt=None, target_prob=0.6, ------- samples : np.array Samples from the MCMC algorithm, including those during adaptation. - """ + """ random_state = np.random.RandomState(seed) n_adapt = n_adapt if n_adapt is not None else n_iter // 2 @@ -157,7 +174,8 @@ def nuts(n_iter, params0, target, grad_target, n_adapt=None, target_prob=0.6, # ******************************** if stepsize is None: grad0 = grad_target(params0) - logger.debug("NUTS: Trying to find initial stepsize from point {} with gradient {}.".format(params0, grad0)) + 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) @@ -176,15 +194,16 @@ def nuts(n_iter, params0, target, grad_target, n_adapt=None, target_prob=0.6, 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)) + 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 + 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 @@ -211,15 +230,15 @@ def nuts(n_iter, params0, target, grad_target, n_adapt=None, target_prob=0.6, # ******** # Sampling # ******** - samples = np.empty((n_iter+1,) + params0.shape) + 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): + for ii in range(1, n_iter + 1): momentum0 = random_state.randn(*params0.shape) - samples_prev = samples[ii-1, :] + samples_prev = samples[ii - 1, :] log_joint0 = target(samples_prev) - 0.5 * momentum0.dot(momentum0) log_slicevar = log_joint0 - random_state.exponential() samples[ii, :] = samples_prev @@ -234,13 +253,15 @@ 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, is_div, is_out \ - = _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, is_div, is_out \ - = _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: @@ -251,7 +272,7 @@ def nuts(n_iter, params0, target, grad_target, n_adapt=None, target_prob=0.6, 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) + and ((params_right - params_left).dot(momentum_right) >= 0) depth += 1 if depth > max_depth: logger.debug("NUTS: Maximum recursion depth {} exceeded.".format(max_depth)) @@ -259,9 +280,10 @@ def nuts(n_iter, params0, target, grad_target, n_adapt=None, target_prob=0.6, # adjust stepsize according to target acceptance ratio if ii <= n_adapt: accept_ratio = (1. - 1. / (ii + ii_offset)) * accept_ratio \ - + (target_prob - float(mh_ratio) / n_steps) / (ii + ii_offset) + + (target_prob - float(mh_ratio) / n_steps) / (ii + ii_offset) log_stepsize = target_stepsize - np.sqrt(ii) / shrinkage * accept_ratio - log_avg_stepsize = ii**discount * log_stepsize + (1. - ii**discount) * log_avg_stepsize + log_avg_stepsize = ii ** discount * log_stepsize + \ + (1. - ii ** discount) * log_avg_stepsize stepsize = np.exp(log_stepsize) elif ii == n_adapt + 1: # adaptation/warmup finished @@ -277,24 +299,25 @@ def nuts(n_iter, params0, target, grad_target, n_adapt=None, target_prob=0.6, 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) + 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)) + logger.warning("NUTS: Diverged proposals after warmup (i.e. n_adapt={} steps): {}".format( + n_adapt, n_diverged)) return samples[1:, :] -def _build_tree_nuts(params, momentum, log_slicevar, step, depth, log_joint0, - target, grad_target, random_state): +def _build_tree_nuts(params, momentum, log_slicevar, step, depth, log_joint0, target, grad_target, + random_state): """Recursively build a balanced binary tree needed by NUTS. Based on Algorithm 6 in Hoffman & Gelman, JMLR 15, 1351-1381, 2014. - """ + """ # Base case: one leapfrog step if depth == 0: momentum1 = momentum + 0.5 * step * grad_target(params) @@ -306,32 +329,37 @@ def _build_tree_nuts(params, momentum, log_slicevar, step, depth, log_joint0, 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 + if np.isinf(target(params1)): # logpdf(params1) = -inf i.e. pdf(params1) = 0 is_out = True else: - logger.debug("NUTS: Diverging error: log_joint={}, params={}, params1={}, momentum={}, momentum1={}" - ".".format(log_joint, params, params1, momentum, momentum1)) + 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)) - return params1, momentum1, params1, momentum1, params1, n_ok, sub_ok, mh_ratio, 1., not sub_ok, is_out + 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, is_div, is_out = _build_tree_nuts(params, momentum, \ - log_slicevar, step, depth-1, log_joint0, target, grad_target, random_state) + 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, is_div, \ - is_out = _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, is_div, \ - is_out = _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(): @@ -339,15 +367,15 @@ def _build_tree_nuts(params, momentum, log_slicevar, step, depth, log_joint0, mh_ratio += mh_ratio2 n_steps += n_steps2 sub_ok = sub_ok and ((params_right - params_left).dot(momentum_left) >= 0) \ - and ((params_right - params_left).dot(momentum_right) >= 0) + and ((params_right - params_left).dot(momentum_right) >= 0) n_sub += n_sub2 return params_left, momentum_left, params_right, momentum_right, params1, n_sub, sub_ok, \ - mh_ratio, n_steps, is_div, is_out + mh_ratio, n_steps, is_div, is_out def metropolis(n_samples, params0, target, sigma_proposals, seed=0): - """Basic Metropolis Markov Chain Monte Carlo sampler with Gaussian proposals. + """Sample the target with a Metropolis Markov Chain Monte Carlo using Gaussian proposals. Parameters ---------- @@ -365,25 +393,26 @@ def metropolis(n_samples, params0, target, sigma_proposals, seed=0): Returns ------- samples : np.array - """ + """ random_state = np.random.RandomState(seed) - samples = np.empty((n_samples+1,) + params0.shape) + samples = np.empty((n_samples + 1, ) + params0.shape) samples[0, :] = params0 target_current = target(params0) n_accepted = 0 - for ii in range(1, n_samples+1): - samples[ii, :] = samples[ii-1, :] + sigma_proposals * random_state.randn(*params0.shape) + for ii in range(1, n_samples + 1): + samples[ii, :] = samples[ii - 1, :] + sigma_proposals * random_state.randn(*params0.shape) target_prev = target_current target_current = target(samples[ii, :]) if np.exp(target_current - target_prev) < random_state.rand(): # reject proposal - samples[ii, :] = samples[ii-1, :] + samples[ii, :] = samples[ii - 1, :] target_current = target_prev else: n_accepted += 1 - logger.info("{}: Total acceptance ratio: {:.3f}".format(__name__, float(n_accepted) / n_samples)) + logger.info( + "{}: Total acceptance ratio: {:.3f}".format(__name__, float(n_accepted) / n_samples)) return samples[1:, :] diff --git a/elfi/methods/parameter_inference.py b/elfi/methods/parameter_inference.py index 98ed0fca..8c70a233 100644 --- a/elfi/methods/parameter_inference.py +++ b/elfi/methods/parameter_inference.py @@ -1,3 +1,7 @@ +"""This module contains common inference methods.""" + +__all__ = ['Rejection', 'SMC', 'BayesianOptimization', 'BOLFI'] + import logging from math import ceil @@ -6,7 +10,6 @@ import elfi.client import elfi.methods.mcmc as mcmc -import elfi.model.augmenter as augmenter import elfi.visualization.interactive as visin import elfi.visualization.visualization as vis from elfi.loader import get_sub_seed @@ -14,16 +17,14 @@ 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, batch_to_arr2d, \ - arr2d_to_batch, ceil_to_batch_size -from elfi.model.elfi_model import ComputationContext, NodeReference, ElfiModel +from elfi.methods.results import BolfiSample, OptimizationResult, Sample, SmcSample +from elfi.methods.utils import (GMDistribution, ModelPrior, arr2d_to_batch, + batch_to_arr2d, ceil_to_batch_size, weighted_var) +from elfi.model.elfi_model import ComputationContext, ElfiModel, NodeReference from elfi.utils import is_array logger = logging.getLogger(__name__) -__all__ = ['Rejection', 'SMC', 'BayesianOptimization', 'BOLFI'] - # TODO: refactor the plotting functions @@ -34,7 +35,7 @@ class ParameterInference: Attributes ---------- model : elfi.ElfiModel - The generative model used by the algorithm + The ELFI graph used by the algorithm output_names : list Names of the nodes whose outputs are included in the batches client : elfi.client.ClientBase @@ -57,7 +58,12 @@ class ParameterInference: """ - def __init__(self, model, output_names, 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. @@ -68,13 +74,13 @@ def __init__(self, model, output_names, batch_size=1000, seed=None, pool=None, model : ElfiModel Model to perform the inference with. output_names : list - Names of the nodes whose outputs will be requested from the generative model. - batch_size : int + Names of the nodes whose outputs will be requested from the ELFI graph. + batch_size : int, optional seed : int, optional Seed for the data generation from the ElfiModel - pool : OutputPool + pool : OutputPool, optional OutputPool both stores and provides precomputed values for batches. - max_parallel_batches : int + max_parallel_batches : int, optional Maximum number of batches allowed to be in computation at the same time. Defaults to number of cores in the client @@ -91,9 +97,8 @@ def __init__(self, model, output_names, batch_size=1000, seed=None, pool=None, # Prepare the computation_context context = ComputationContext(batch_size=batch_size, seed=seed, pool=pool) - self.batches = elfi.client.BatchHandler(self.model, context=context, - output_names=output_names, - client=self.client) + self.batches = elfi.client.BatchHandler( + self.model, context=context, output_names=output_names, client=self.client) self.computation_context = context self.max_parallel_batches = max_parallel_batches or self.client.num_cores @@ -140,6 +145,7 @@ def set_objective(self, *args, **kwargs): Returns ------- None + """ raise NotImplementedError @@ -151,6 +157,7 @@ def extract_result(self): Returns ------- result : elfi.methods.result.Result + """ raise NotImplementedError @@ -171,12 +178,13 @@ def update(self, batch, batch_index): Returns ------- None + """ self.state['n_batches'] += 1 self.state['n_sim'] += self.batch_size def prepare_new_batch(self, batch_index): - """Prepare values for a new batch + """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 @@ -231,6 +239,7 @@ def infer(self, *args, vis=None, **kwargs): Returns ------- result : Sample + """ vis_opt = vis if isinstance(vis, dict) else {} @@ -248,7 +257,7 @@ def infer(self, *args, vis=None, **kwargs): return self.extract_result() def iterate(self): - """Forward the inference one iteration. + """Advance the inference by 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 @@ -268,16 +277,15 @@ def iterate(self): None """ - # Submit new batches if allowed while self._allow_submit(self.batches.next_index): next_batch = self.prepare_new_batch(self.batches.next_index) - logger.info("Submitting batch %d" % self.batches.next_index) + logger.debug("Submitting batch %d" % self.batches.next_index) self.batches.submit(next_batch) # Handle the next ready batch in succession batch, batch_index = self.batches.wait_next() - logger.info('Received batch %d' % batch_index) + logger.debug('Received batch %d' % batch_index) self.update(batch, batch_index) @property @@ -286,29 +294,27 @@ def finished(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()) + self._has_batches_to_submit and \ + (not self.batches.has_ready()) @property def _has_batches_to_submit(self): return self._objective_n_batches > \ - self.state['n_batches'] + self.batches.num_pending + self.state['n_batches'] + self.batches.num_pending @property def _objective_n_batches(self): - """Checks that n_batches can be computed from the objective""" + """Check 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) + 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 _extract_result_kwargs(self): - """Extract common arguments for the ParameterInferenceResult object from the - inference instance. - """ + """Extract common arguments for the ParameterInferenceResult object.""" return { 'method_name': self.__class__.__name__, 'parameter_names': self.parameter_names, @@ -335,8 +341,10 @@ def _resolve_model(model, target, default_reference_class=NodeReference): return model, target.name def _check_outputs(self, output_names): - """Filters out duplicates, checks that corresponding nodes exist and preserves - the order.""" + """Filter out duplicates and check that corresponding nodes exist. + + Preserves the order. + """ output_names = output_names or [] checked_names = [] seen = set() @@ -347,7 +355,8 @@ def _check_outputs(self, output_names): if name in seen: continue elif not isinstance(name, str): - raise ValueError('All output names must be strings, object {} was given'.format(name)) + 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.') @@ -359,7 +368,7 @@ def _check_outputs(self, output_names): class Sampler(ParameterInference): def sample(self, n_samples, *args, **kwargs): - """Sample from the approximate posterior + """Sample from the approximate posterior. See the other arguments from the `set_objective` method. @@ -373,8 +382,8 @@ def sample(self, n_samples, *args, **kwargs): Returns ------- result : Sample - """ + """ return self.infer(n_samples, *args, **kwargs) def _extract_result_kwargs(self): @@ -398,23 +407,24 @@ class Rejection(Sampler): Lintusaari J, Gutmann M U, Dutta R, Kaski S, Corander J (2016). Fundamentals and Recent Developments in Approximate Bayesian Computation. Systematic Biology. http://dx.doi.org/10.1093/sysbio/syw077. + """ def __init__(self, model, discrepancy_name=None, output_names=None, **kwargs): - """ + """Initialize the Rejection sampler. Parameters ---------- model : ElfiModel or NodeReference discrepancy_name : str, NodeReference, optional Only needed if model is an ElfiModel - output_names : list + output_names : list, optional Additional outputs from the model to be included in the inference result, e.g. corresponding summaries to the acquired samples kwargs: See InferenceMethod - """ + """ 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) @@ -422,7 +432,7 @@ def __init__(self, model, discrepancy_name=None, output_names=None, **kwargs): self.discrepancy_name = discrepancy_name def set_objective(self, n_samples, threshold=None, quantile=None, n_sim=None): - """ + """Set objective for inference. Parameters ---------- @@ -437,30 +447,36 @@ def set_objective(self, n_samples, threshold=None, quantile=None, n_sim=None): Total number of simulations. The threshold will be the n_samples smallest discrepancy among n_sim simulations. - Returns - ------- - """ if quantile is None and threshold is None and n_sim is None: quantile = .01 - self.state = dict(samples=None, threshold=np.Inf, n_sim=0, accept_rate=1, - n_batches=0) + self.state = dict(samples=None, threshold=np.Inf, n_sim=0, accept_rate=1, n_batches=0) - if quantile: n_sim = ceil(n_samples/quantile) + if quantile: + n_sim = ceil(n_samples / quantile) # Set initial n_batches estimate if n_sim: - n_batches = ceil(n_sim/self.batch_size) + n_batches = ceil(n_sim / self.batch_size) else: n_batches = self.max_parallel_batches - self.objective = dict(n_samples=n_samples, threshold=threshold, - n_batches=n_batches) + self.objective = dict(n_samples=n_samples, threshold=threshold, n_batches=n_batches) # Reset the inference self.batches.reset() def update(self, batch, batch_index): + """Update the inference state with a new batch. + + Parameters + ---------- + batch : dict + dict with `self.outputs` as keys and the corresponding outputs for the batch + as values + batch_index : int + + """ super(Rejection, self).update(batch, batch_index) if self.state['samples'] is None: # Lazy initialization of the outputs dict @@ -470,11 +486,12 @@ def update(self, batch, batch_index): self._update_objective_n_batches() def extract_result(self): - """Extracts the result from the current state + """Extract the result from the current state. Returns ------- result : Sample + """ if self.state['samples'] is None: raise ValueError('Nothing to extract') @@ -487,10 +504,10 @@ def extract_result(self): 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 = {} 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 {}." + e_len = "Node {} output has array length {}. It should be equal to the batch size {}." for node in self.output_names: # Check the requested outputs @@ -504,7 +521,7 @@ def _init_samples_lazy(self, batch): raise ValueError(e_len.format(node, len(nbatch), self.batch_size)) # Prepare samples - shape = (self.objective['n_samples'] + self.batch_size,) + nbatch.shape[1:] + shape = (self.objective['n_samples'] + self.batch_size, ) + nbatch.shape[1:] dtype = nbatch.dtype if node == self.discrepancy_name: @@ -528,16 +545,16 @@ def _merge_batch(self, batch): v[:] = v[sort_mask] def _update_state_meta(self): - """Updates n_sim, threshold, and accept_rate - """ + """Update `n_sim`, `threshold`, and `accept_rate`.""" o = self.objective s = self.state s['threshold'] = s['samples'][self.discrepancy_name][o['n_samples'] - 1].item() - s['accept_rate'] = min(1, o['n_samples']/s['n_sim']) + s['accept_rate'] = min(1, o['n_samples'] / s['n_sim']) def _update_objective_n_batches(self): # Only in the case that the threshold is used - if not self.objective.get('threshold'): return + if not self.objective.get('threshold'): + return s = self.state t, n_samples = [self.objective.get(k) for k in ('threshold', 'n_samples')] @@ -567,57 +584,84 @@ def plot_state(self, **options): displays = [] if options.get('interactive'): from IPython import display - displays.append(display.HTML( - 'Threshold: {}'.format(self.state['threshold']))) + displays.append( + display.HTML('Threshold: {}'.format(self.state['threshold']))) - visin.plot_sample(self.state['samples'], nodes=self.parameter_names, - 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""" + """Sequential Monte Carlo ABC sampler.""" + def __init__(self, model, discrepancy_name=None, output_names=None, **kwargs): - model, discrepancy_name = self._resolve_model(model, discrepancy_name) + """Initialize the SMC-ABC sampler. - # Add the prior pdf nodes to the model - model = model.copy() - logpdf_name = augmenter.add_pdf_nodes(model, log=True)[0] + Parameters + ---------- + model : ElfiModel or NodeReference + discrepancy_name : str, NodeReference, optional + Only needed if model is an ElfiModel + output_names : list, optional + Additional outputs from the model to be included in the inference result, e.g. + corresponding summaries to the acquired samples + kwargs: + See InferenceMethod - output_names = [discrepancy_name] + model.parameter_names + [logpdf_name] + \ - (output_names or []) + """ + model, discrepancy_name = self._resolve_model(model, discrepancy_name) super(SMC, self).__init__(model, output_names, **kwargs) + self._prior = ModelPrior(self.model) self.discrepancy_name = discrepancy_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)) + """Set the objective of the inference.""" + self.objective.update( + dict( + n_samples=n_samples, + n_batches=self.max_parallel_batches, + round=len(thresholds) - 1, + thresholds=thresholds)) self._init_new_round() def extract_result(self): - """ + """Extract the result from the current state. Returns ------- SmcSample + """ # Extract information from the population pop = self._extract_population() - return SmcSample(outputs=pop.outputs, - populations=self._populations.copy() + [pop], - weights=pop.weights, - threshold=pop.threshold, - **self._extract_result_kwargs()) + return SmcSample( + outputs=pop.outputs, + populations=self._populations.copy() + [pop], + weights=pop.weights, + threshold=pop.threshold, + **self._extract_result_kwargs()) def update(self, batch, batch_index): + """Update the inference state with a new batch. + + Parameters + ---------- + batch : dict + dict with `self.outputs` as keys and the corresponding outputs for the batch + as values + batch_index : int + + """ super(SMC, self).update(batch, batch_index) self._rejection.update(batch, batch_index) @@ -631,12 +675,27 @@ def update(self, batch, batch_index): self._update_objective() def prepare_new_batch(self, batch_index): + """Prepare values for a new batch. + + Parameters + ---------- + batch_index : int + next batch_index to be submitted + + 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. + + """ if self.state['round'] == 0: # Use the actual prior return - # Sample from the proposal + # Sample from the proposal, condition on actual prior params = GMDistribution.rvs(*self._gm_params, size=self.batch_size, + prior_logpdf=self._prior.logpdf, random_state=self._round_random_state) batch = arr2d_to_batch(params, self.parameter_names) @@ -645,22 +704,23 @@ def prepare_new_batch(self, batch_index): def _init_new_round(self): round = self.state['round'] - dashes = '-'*16 + dashes = '-' * 16 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=seed, - max_parallel_batches=self.max_parallel_batches) + self._rejection = Rejection( + self.model, + discrepancy_name=self.discrepancy_name, + output_names=self.output_names, + batch_size=self.batch_size, + seed=seed, + max_parallel_batches=self.max_parallel_batches) - self._rejection.set_objective(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): sample = self._rejection.extract_result() @@ -676,7 +736,8 @@ def _compute_weights_and_cov(self, pop): if self._populations: q_logpdf = GMDistribution.logpdf(params, *self._gm_params) - w = np.exp(pop.outputs[self.prior_logpdf] - q_logpdf) + p_logpdf = self._prior.logpdf(params) + w = np.exp(p_logpdf - q_logpdf) else: w = np.ones(pop.n_samples) @@ -697,7 +758,7 @@ def _compute_weights_and_cov(self, pop): return w, cov def _update_objective(self): - """Updates the objective n_batches""" + """Update the objective n_batches.""" n_batches = sum([pop.n_batches for pop in self._populations]) self.objective['n_batches'] = n_batches + self._rejection.objective['n_batches'] @@ -709,30 +770,42 @@ def _gm_params(self): @property def current_population_threshold(self): + """Return the threshold for current population.""" return self.objective['thresholds'][self.state['round']] class BayesianOptimization(ParameterInference): """Bayesian Optimization of an unknown target function.""" - 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, async=False, **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, + async=False, + **kwargs): + """Initialize Bayesian optimization. + Parameters ---------- model : ElfiModel or NodeReference target_name : str or NodeReference Only needed if model is an ElfiModel - bounds : dict + bounds : dict, optional The region where to estimate the posterior for each parameter in 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. Default value depends on the dimensionality. - update_interval : int + update_interval : int, optional How often to update the GP hyperparameters of the target_model target_model : GPyRegression, optional acquisition_method : Acquisition, optional @@ -747,22 +820,22 @@ def __init__(self, model, target_name=None, bounds=None, initial_evidence=None, batches_per_acquisition : int, optional How many batches will be requested from the acquisition function at one go. Defaults to max_parallel_batches. - async : bool + async : bool, optional 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 - """ + """ 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) + super(BayesianOptimization, self).__init__( + model, output_names, batch_size=batch_size, **kwargs) target_model = target_model or \ - GPyRegression(self.model.parameter_names, bounds=bounds) + GPyRegression(self.model.parameter_names, bounds=bounds) self.target_name = target_name self.target_model = target_model @@ -776,11 +849,11 @@ def __init__(self, model, target_name=None, bounds=None, initial_evidence=None, 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) + 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 @@ -810,27 +883,30 @@ def _resolve_initial_evidence(self, initial_evidence): '(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)) + 'the initialization (now {})'.format(n_required, n_initial_evidence)) 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)) + '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) return n_initial_evidence, precomputed @property def n_evidence(self): + """Return the number of acquired evidence points.""" return self.state.get('n_evidence', 0) @property def acq_batch_size(self): - return self.batch_size*self.batches_per_acquisition + """Return the total number of acquisition per iteration.""" + 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 + """Set objective for inference. + + You can continue BO by giving a larger n_evidence. Parameters ---------- @@ -849,20 +925,33 @@ def set_objective(self, n_evidence=None): self.objective['n_sim'] = n_evidence - self.n_precomputed_evidence def extract_result(self): - x_min, _ = stochastic_optimization(self.target_model.predict_mean, - self.target_model.bounds, - seed=self.seed) + """Extract the result from the current state. + + Returns + ------- + OptimizationResult + + """ + x_min, _ = stochastic_optimization( + self.target_model.predict_mean, self.target_model.bounds, seed=self.seed) 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 - return OptimizationResult(x_min=batch_min, - outputs=outputs, - **self._extract_result_kwargs()) + 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. + """Update the GP regression model of the target node with a new batch. + + Parameters + ---------- + batch : dict + dict with `self.outputs` as keys and the corresponding outputs for the batch + as values + batch_index : int + """ super(BayesianOptimization, self).update(batch, batch_index) self.state['n_evidence'] += self.batch_size @@ -876,10 +965,25 @@ def update(self, batch, batch_index): self.state['last_GP_update'] = self.target_model.n_evidence def prepare_new_batch(self, batch_index): + """Prepare values for a new batch. + + Parameters + ---------- + batch_index : int + next batch_index to be submitted + + 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. + + """ t = self._get_acquisition_index(batch_index) # Check if we still should take initial points from the prior - if t < 0: return + if t < 0: + return # Take the next batch from the acquisition_batch acquisition = self.state['acquisition'] @@ -937,24 +1041,25 @@ def _report_batch(self, batch_index, params, distances): logger.debug(str) def plot_state(self, **options): - """Plot the GP surface - + """Plot the GP surface. + This feature is still experimental and 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') + f, _ = plt.subplots(1, 2, figsize=(13, 6), sharex='row', sharey='row') gp = self.target_model # Draw the GP surface - visin.draw_contour(gp.predict_mean, - gp.bounds, - self.parameter_names, - title='GP target surface', - points = gp.X, - axes=f.axes[0], **options) + visin.draw_contour( + gp.predict_mean, + gp.bounds, + self.parameter_names, + title='GP target surface', + points=gp.X, + axes=f.axes[0], + **options) # Draw the latest acquisitions if options.get('interactive'): @@ -966,21 +1071,26 @@ def plot_state(self, **options): if options.get('interactive'): from IPython import display - displays.insert(0, display.HTML( - 'Iteration {}: Acquired {} at {}'.format( - len(gp.Y), gp.Y[-1][0], point))) + displays.insert( + 0, + display.HTML('Iteration {}: Acquired {} at {}'.format( + len(gp.Y), gp.Y[-1][0], point))) # Update visin._update_interactive(displays, options) - acq = lambda x : self.acquisition_method.evaluate(x, len(gp.X)) + def acq(x): + return self.acquisition_method.evaluate(x, len(gp.X)) + # Draw the acquisition surface - visin.draw_contour(acq, - gp.bounds, - self.parameter_names, - title='Acquisition surface', - points = None, - axes=f.axes[1], **options) + visin.draw_contour( + acq, + gp.bounds, + self.parameter_names, + title='Acquisition surface', + points=None, + axes=f.axes[1], + **options) if options.get('close'): plt.close() @@ -992,7 +1102,6 @@ def plot_discrepancy(self, axes=None, **kwargs): """ n_plots = self.target_model.input_dim ncols = kwargs.pop('ncols', 5) - nrows = kwargs.pop('nrows', 1) kwargs['sharey'] = kwargs.get('sharey', True) shape = (max(1, n_plots // ncols), min(n_plots, ncols)) axes, kwargs = vis._create_axes(axes, shape, **kwargs) @@ -1025,39 +1134,60 @@ class BOLFI(BayesianOptimization): """ 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. + """Fit the surrogate model. + + Generates a regression model for the discrepancy given the parameters. + + Currently only Gaussian processes are supported as surrogate models. + + Parameters + ---------- + threshold : float, optional + Discrepancy threshold for creating the posterior (log with log discrepancy). + """ logger.info("BOLFI: Fitting the surrogate model...") if n_evidence is None: - raise ValueError('You must specify the number of evidence (n_evidence) for the fitting') + 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. + """Return an object representing the approximate posterior. + + The approximation is based on surrogate model regression. Parameters ---------- - threshold: float + threshold: float, optional Discrepancy threshold for creating the posterior (log with log discrepancy). Returns ------- posterior : elfi.methods.posteriors.BolfiPosterior + """ if self.state['n_batches'] == 0: 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', n_evidence=None, **kwargs): - """Sample the posterior distribution of BOLFI, where the likelihood is defined through - the cumulative density function of standard normal distribution: + def sample(self, + n_samples, + warmup=None, + n_chains=4, + threshold=None, + initials=None, + algorithm='nuts', + n_evidence=None, + **kwargs): + r"""Sample the posterior distribution of BOLFI. + + Here the likelihood is defined through the cumulative density function + of the standard normal distribution: L(\theta) \propto F((h-\mu(\theta)) / \sigma(\theta)) @@ -1078,7 +1208,8 @@ def sample(self, n_samples, warmup=None, n_chains=4, threshold=None, initials=No threshold : float, optional The threshold (bandwidth) for posterior (give as log if log discrepancy). initials : np.array of shape (n_chains, n_params), optional - Initial values for the sampled parameters for each chain. Defaults to best evidence points. + 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 @@ -1086,13 +1217,13 @@ def sample(self, n_samples, warmup=None, n_chains=4, threshold=None, initials=No Returns ------- - np.array - """ + BolfiSample + """ if self.state['n_batches'] == 0: self.fit(n_evidence) - #TODO: other MCMC algorithms + # TODO: other MCMC algorithms posterior = self.extract_posterior(threshold) warmup = warmup or n_samples // 2 @@ -1102,25 +1233,34 @@ 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: - inds = np.argsort(self.target_model.Y[:,0]) + 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 - 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) - while np.isinf(posterior.logpdf(initials[ii_initial])): # discard bad initialization points + seed = get_sub_seed(self.seed, ii) + # discard bad initialization points + while np.isinf(posterior.logpdf(initials[ii_initial])): ii_initial += 1 if ii_initial == len(inds): - 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)) + 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)) ii_initial += 1 # get results from completed tasks or run sampling (client-specific) @@ -1130,18 +1270,20 @@ def sample(self, n_samples, warmup=None, n_chains=4, threshold=None, initials=No chains = np.asarray(chains) - print("{} chains of {} iterations acquired. Effective sample size and Rhat for each parameter:" - .format(n_chains, n_samples)) + print( + "{} chains of {} iterations acquired. Effective sample size and Rhat for each " + "parameter:".format(n_chains, n_samples)) for ii, node in enumerate(self.parameter_names): - print(node, mcmc.eff_sample_size(chains[:, :, ii]), mcmc.gelman_rubin(chains[:, :, ii])) + print(node, mcmc.eff_sample_size(chains[:, :, ii]), + mcmc.gelman_rubin(chains[:, :, ii])) self.target_model.is_sampling = False - return BolfiSample(method_name='BOLFI', - chains=chains, - parameter_names=self.parameter_names, - warmup=warmup, - threshold=float(posterior.threshold), - n_sim=self.state['n_sim'], - seed=self.seed - ) + return BolfiSample( + method_name='BOLFI', + chains=chains, + parameter_names=self.parameter_names, + warmup=warmup, + threshold=float(posterior.threshold), + n_sim=self.state['n_sim'], + seed=self.seed) diff --git a/elfi/methods/post_processing.py b/elfi/methods/post_processing.py index d6921c4e..384c7abc 100644 --- a/elfi/methods/post_processing.py +++ b/elfi/methods/post_processing.py @@ -1,5 +1,4 @@ -""" -Post-processing for posterior samples from other ABC algorithms. +"""Post-processing for posterior samples from other ABC algorithms. References ---------- @@ -7,15 +6,15 @@ 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 sklearn.linear_model import LinearRegression from . import results - __all__ = ('LinearAdjustment', 'adjust_posterior') @@ -48,7 +47,9 @@ class RegressionAdjustment(object): the sample object from an ABC algorithm X the regressors for the regression model + """ + _regression_model = None _name = 'RegressionAdjustment' @@ -78,8 +79,7 @@ def X(self): def _check_fitted(self): if not self._fitted: - raise ValueError("The regression model must be fitted first. " - "Use the fit() method.") + 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. @@ -97,6 +97,7 @@ def fit(self, sample, model, summary_names, parameter_names=None): 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 @@ -127,6 +128,7 @@ def adjust(self): Returns ------- a Sample object containing the adjusted posterior + """ outputs = {} for (i, name) in enumerate(self.parameter_names): @@ -134,8 +136,8 @@ def adjust(self): 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) + res = results.Sample( + method_name=self._name, outputs=outputs, parameter_names=self._parameter_names) return res def _adjust(self, i, theta_i, regression_model): @@ -154,6 +156,7 @@ def _adjust(self, i, theta_i, regression_model): ------- adjusted_theta_i : np.ndarray an adjusted version of the parameter values + """ raise NotImplementedError @@ -173,14 +176,16 @@ def _input_variables(self, model, sample, summary_names): ------- 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] + 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): @@ -189,27 +194,22 @@ def _get_finite(self): 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) + 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'): +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 @@ -239,17 +239,16 @@ def adjust_posterior(sample, model, summary_names, 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) + adjustment.fit( + model=model, sample=sample, parameter_names=parameter_names, summary_names=summary_names) return adjustment.adjust() @@ -262,5 +261,4 @@ def _get_adjustment(adjustment): try: return adjustments.get(adjustment, None)() except TypeError: - raise ValueError("Could not find " - "adjustment method:{}".format(adjustment)) + raise ValueError("Could not find " "adjustment method:{}".format(adjustment)) diff --git a/elfi/methods/posteriors.py b/elfi/methods/posteriors.py index a26798a5..f659dcb5 100644 --- a/elfi/methods/posteriors.py +++ b/elfi/methods/posteriors.py @@ -1,20 +1,21 @@ +"""The module contains implementations of approximate posteriors.""" + import logging -import numpy as np -import scipy.stats as ss import matplotlib.pyplot as plt +import numpy as np +import scipy.stats as ss from elfi.methods.bo.utils import minimize - logger = logging.getLogger(__name__) # 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 + r"""Container for the approximate posterior in the BOLFI framework. + + Here the likelihood is defined as L \propto F((h - \mu) / \sigma) @@ -29,23 +30,27 @@ class BolfiPosterior: of Simulator-Based Statistical Models. JMLR 17(125):1−47, 2016. http://jmlr.org/papers/v17/15-017.html - Parameters - ---------- - 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. - prior : ScipyLikeDistribution, 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, prior=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): + """Initialize a BOLFI posterior. + + Parameters + ---------- + 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. + prior : ScipyLikeDistribution, 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. + seed : int, optional + + """ super(BolfiPosterior, self).__init__() self.threshold = threshold self.model = model @@ -58,24 +63,28 @@ 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.bounds, - self.model.predictive_gradient_mean, - self.prior, - self.n_inits, - self.max_opt_iters, - random_state=self.random_state) + minloc, minval = minimize( + self.model.predict_mean, + self.model.bounds, + self.model.predictive_gradient_mean, + self.prior, + self.n_inits, + self.max_opt_iters, + random_state=self.random_state) self.threshold = minval logger.info("Using optimized minimum value (%.4f) of the GP discrepancy mean " "function as a threshold" % (self.threshold)) def rvs(self, size=None, random_state=None): + """Sample the posterior. + + Currently unimplemented. Please use a sampler to sample from the posterior. + """ raise NotImplementedError('Currently not implemented. Please use a sampler to ' 'sample from the posterior.') def logpdf(self, x): - """ - Returns the unnormalized log-posterior pdf at x. + """Return the unnormalized log-posterior pdf at x. Parameters ---------- @@ -84,12 +93,12 @@ def logpdf(self, x): Returns ------- float + """ return self._unnormalized_loglikelihood(x) + self.prior.logpdf(x) def pdf(self, x): - """ - Returns the unnormalized posterior pdf at x. + """Return the unnormalized posterior pdf at x. Parameters ---------- @@ -98,12 +107,12 @@ def pdf(self, x): Returns ------- float + """ return np.exp(self.logpdf(x)) def gradient_logpdf(self, x): - """ - Returns the gradient of the unnormalized log-posterior pdf at x. + """Return the gradient of the unnormalized log-posterior pdf at x. Parameters ---------- @@ -112,13 +121,13 @@ def gradient_logpdf(self, x): Returns ------- np.array - """ + """ grads = self._gradient_unnormalized_loglikelihood(x) + \ - self.prior.gradient_logpdf(x) + self.prior.gradient_logpdf(x) # nan grads are result from -inf logpdf - #return np.where(np.isnan(grads), 0, grads)[0] + # return np.where(np.isnan(grads), 0, grads)[0] return grads def _unnormalized_loglikelihood(self, x): @@ -126,19 +135,19 @@ def _unnormalized_loglikelihood(self, x): ndim = x.ndim x = x.reshape((-1, self.dim)) - logpdf = -np.ones(len(x))*np.inf + logpdf = -np.ones(len(x)) * np.inf logi = self._within_bounds(x) - x = x[logi,:] + x = x[logi, :] if len(x) == 0: - if ndim == 0 or (ndim==1 and self.dim > 1): + if ndim == 0 or (ndim == 1 and self.dim > 1): logpdf = logpdf[0] return logpdf mean, var = self.model.predict(x) logpdf[logi] = ss.norm.logcdf(self.threshold, mean, np.sqrt(var)).squeeze() - if ndim == 0 or (ndim==1 and self.dim > 1): + if ndim == 0 or (ndim == 1 and self.dim > 1): logpdf = logpdf[0] return logpdf @@ -151,9 +160,9 @@ def _gradient_unnormalized_loglikelihood(self, x): grad = np.zeros_like(x) logi = self._within_bounds(x) - x = x[logi,:] + x = x[logi, :] if len(x) == 0: - if ndim == 0 or (ndim==1 and self.dim > 1): + if ndim == 0 or (ndim == 1 and self.dim > 1): grad = grad[0] return grad @@ -170,7 +179,7 @@ def _gradient_unnormalized_loglikelihood(self, x): grad[logi, :] = factor * pdf / cdf - if ndim == 0 or (ndim==1 and self.dim > 1): + if ndim == 0 or (ndim == 1 and self.dim > 1): grad = grad[0] return grad @@ -201,13 +210,14 @@ def _within_bounds(self, x): 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 @@ -228,12 +238,13 @@ def plot(self, logpdf=False): plt.figure() plt.plot(x, pd) plt.xlim(mn, mx) - plt.ylim(min(pd)*1.05, max(pd)*1.05) + 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) + 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() diff --git a/elfi/methods/results.py b/elfi/methods/results.py index 4d59f0b2..93855499 100644 --- a/elfi/methods/results.py +++ b/elfi/methods/results.py @@ -1,3 +1,5 @@ +"""Containers for results from inference.""" + import io import logging import sys @@ -12,8 +14,10 @@ class ParameterInferenceResult: + """Base class for results.""" + def __init__(self, method_name, outputs, parameter_names, **kwargs): - """ + """Initialize result. Parameters ---------- @@ -34,8 +38,10 @@ def __init__(self, method_name, outputs, parameter_names, **kwargs): class OptimizationResult(ParameterInferenceResult): + """Base class for results from optimization.""" + def __init__(self, x_min, **kwargs): - """ + """Initialize result. Parameters ---------- @@ -50,22 +56,34 @@ def __init__(self, x_min, **kwargs): class Sample(ParameterInferenceResult): - """Sampling results from the methods. - - """ - def __init__(self, method_name, outputs, parameter_names, discrepancy_name=None, - weights=None, **kwargs): - """ + """Sampling results from inference methods.""" + + def __init__(self, + method_name, + outputs, + parameter_names, + discrepancy_name=None, + weights=None, + **kwargs): + """Initialize result. 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 discrepancy_name : string, optional Name of the discrepancy in outputs. + weights : array_like **kwargs Other meta information for the result + """ - super(Sample, self).__init__(method_name=method_name, outputs=outputs, - parameter_names=parameter_names, **kwargs) + super(Sample, self).__init__( + method_name=method_name, outputs=outputs, parameter_names=parameter_names, **kwargs) self.samples = OrderedDict() for n in self.parameter_names: @@ -75,15 +93,15 @@ def __init__(self, method_name, outputs, parameter_names, discrepancy_name=None, self.weights = weights def __getattr__(self, item): - """Allows more convenient access to items under self.meta. - """ + """Allow more convenient access to items under self.meta.""" if item in self.meta.keys(): return self.meta[item] else: raise AttributeError("No attribute '{}' in this sample".format(item)) def __dir__(self): - """Allows autocompletion for items under self.meta. + """Allow autocompletion for items under self.meta. + http://stackoverflow.com/questions/13603088/python-dynamic-help-and-autocomplete-generation """ items = dir(type(self)) + list(self.__dict__.keys()) @@ -92,30 +110,35 @@ def __dir__(self): @property def n_samples(self): + """Return the number of samples.""" return len(self.outputs[self.parameter_names[0]]) @property def dim(self): + """Return the number of parameters.""" return len(self.parameter_names) @property def discrepancies(self): + """Return the discrepancy values.""" return None if self.discrepancy_name is None else \ self.outputs[self.discrepancy_name] @property def samples_array(self): - """ - Return the samples as an array with columns in the same order as in - self.parameter_names. + """Return the samples as an array. + + The columns are in the same order as in self.parameter_names. Returns ------- list of np.arrays + """ return np.column_stack(tuple(self.samples.values())) def __str__(self): + """Return a summary of results as a string.""" # create a buffer for capturing the output from summary's print statement stdout0 = sys.stdout buffer = io.StringIO() @@ -125,14 +148,14 @@ def __str__(self): return buffer.getvalue() def __repr__(self): + """Return a summary of results as a string.""" return self.__str__() def summary(self): - """Print a verbose summary of contained results. - """ + """Print a verbose summary of contained results.""" # TODO: include __str__ of Inference Task, seed? - desc = "Method: {}\nNumber of samples: {}\n"\ - .format(self.method_name, self.n_samples) + desc = "Method: {}\nNumber of samples: {}\n" \ + .format(self.method_name, self.n_samples) if hasattr(self, 'n_sim'): desc += "Number of simulations: {}\n".format(self.n_sim) if hasattr(self, 'threshold'): @@ -141,19 +164,32 @@ def summary(self): self.sample_means_summary() def sample_means_summary(self): - """Print a representation of posterior means. - """ + """Print a representation of sample means.""" s = "Sample means: " - s += ', '.join(["{}: {:.3g}".format(k, v) for k,v in self.sample_means.items()]) + s += ', '.join(["{}: {:.3g}".format(k, v) for k, v in self.sample_means.items()]) print(s) @property def sample_means(self): - return OrderedDict([(k, np.average(v, axis=0, weights=self.weights)) for \ - k,v in self.samples.items()]) + """Evaluate weighted averages of sampled parameters. + + Returns + ------- + OrderedDict + + """ + return OrderedDict([(k, np.average(v, axis=0, weights=self.weights)) + for k, v in self.samples.items()]) @property def sample_means_array(self): + """Evaluate weighted averages of sampled parameters. + + Returns + ------- + np.array + + """ return np.array(list(self.sample_means.values())) def plot_marginals(self, selector=None, bins=20, axes=None, **kwargs): @@ -170,6 +206,7 @@ def plot_marginals(self, selector=None, bins=20, axes=None, **kwargs): Returns ------- axes : np.array of plt.Axes + """ return vis.plot_marginals(self.samples, selector, bins, axes, **kwargs) @@ -189,16 +226,16 @@ def plot_pairs(self, selector=None, bins=20, axes=None, **kwargs): Returns ------- axes : np.array of plt.Axes + """ return vis.plot_pairs(self.samples, selector, bins, axes, **kwargs) class SmcSample(Sample): - """Container for results from SMC-ABC. - """ - def __init__(self, method_name, outputs, parameter_names, populations, *args, - **kwargs): - """ + """Container for results from SMC-ABC.""" + + def __init__(self, method_name, outputs, parameter_names, populations, *args, **kwargs): + """Initialize result. Parameters ---------- @@ -209,9 +246,14 @@ def __init__(self, method_name, outputs, parameter_names, populations, *args, List of Sample objects args kwargs + """ - super(SmcSample, self).__init__(method_name=method_name, outputs=outputs, - parameter_names=parameter_names, *args, **kwargs) + super(SmcSample, self).__init__( + method_name=method_name, + outputs=outputs, + parameter_names=parameter_names, + *args, + **kwargs) self.populations = populations if self.weights is None: @@ -219,9 +261,19 @@ def __init__(self, method_name, outputs, parameter_names, populations, *args, @property def n_populations(self): + """Return the number of populations.""" return len(self.populations) def summary(self, all=False): + """Print a verbose summary of contained results. + + Parameters + ---------- + all : bool, optional + Whether to print the summary for all populations separately, + or just the final population (default). + + """ super(SmcSample, self).summary() if all: @@ -230,6 +282,15 @@ def summary(self, all=False): pop.summary() def sample_means_summary(self, all=False): + """Print a representation of sample means. + + Parameters + ---------- + all : bool, optional + Whether to print the means for all populations separately, + or just the final population (default). + + """ if all is False: super(SmcSample, self).sample_means_summary() return @@ -253,6 +314,7 @@ def plot_marginals(self, selector=None, bins=20, axes=None, all=False, **kwargs) axes : one or an iterable of plt.Axes, optional all : bool, optional Plot the marginals of all populations + """ if all is False: super(SmcSample, self).plot_marginals() @@ -264,7 +326,7 @@ def plot_marginals(self, selector=None, bins=20, axes=None, all=False, **kwargs) plt.suptitle("Population {}".format(i), fontsize=fontsize) def plot_pairs(self, selector=None, bins=20, axes=None, all=False, **kwargs): - """Plot pairwise relationships as a matrix with marginals on the diagonal for all populations. + """Plot pairwise relationships as a matrix with marginals on the diagonal. The y-axis of marginal histograms are scaled. @@ -277,8 +339,8 @@ def plot_pairs(self, selector=None, bins=20, axes=None, all=False, **kwargs): axes : one or an iterable of plt.Axes, optional all : bool, optional Plot for all populations - """ + """ if all is False: super(SmcSample, self).plot_marginals() return @@ -290,31 +352,39 @@ def plot_pairs(self, selector=None, bins=20, axes=None, all=False, **kwargs): class BolfiSample(Sample): - """Container for results from BOLFI. - - Parameters - ---------- - method_name : string - Name of inference method. - chains : np.array - Chains from sampling. Shape should be (n_chains, n_samples, n_parameters) with warmup included. - parameter_names : list : list of strings - List of names in the outputs dict that refer to model parameters. - warmup : int - Number of warmup iterations in chains. - """ + """Container for results from BOLFI.""" + def __init__(self, method_name, chains, parameter_names, warmup, **kwargs): + """Initialize result. + + Parameters + ---------- + method_name : string + Name of inference method. + chains : np.array + Chains from sampling, warmup included. Shape: (n_chains, n_samples, n_parameters). + parameter_names : list : list of strings + List of names in the outputs dict that refer to model parameters. + warmup : int + Number of warmup iterations in chains. + + """ chains = chains.copy() shape = chains.shape n_chains = shape[0] warmed_up = chains[:, warmup:, :] - concatenated = warmed_up.reshape((-1,) + shape[2:]) + concatenated = warmed_up.reshape((-1, ) + shape[2:]) outputs = dict(zip(parameter_names, concatenated.T)) - super(BolfiSample, 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): + """Plot MCMC traces.""" return vis.plot_traces(self, selector, axes, **kwargs) diff --git a/elfi/methods/utils.py b/elfi/methods/utils.py index 600e04cf..9c3c4edc 100644 --- a/elfi/methods/utils.py +++ b/elfi/methods/utils.py @@ -1,19 +1,20 @@ +"""This module contains utilities for methods.""" + import logging from math import ceil 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 +from elfi.model.elfi_model import ComputationContext logger = logging.getLogger(__name__) def arr2d_to_batch(x, names): - """Convert 2d array to batch dictionary columnwise + """Convert a 2d array to a batch dictionary columnwise. Parameters ---------- @@ -31,16 +32,16 @@ def arr2d_to_batch(x, names): # TODO: support vector parameter nodes try: x = x.reshape((-1, len(names))) - except: + except BaseException: 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)} + 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 + """Convert batches into a single numpy array. Parameters ---------- @@ -55,7 +56,6 @@ def batch_to_arr2d(batches, names): 2d, where columns are batch outputs """ - if not batches: return [] if not isinstance(batches, list): @@ -69,17 +69,26 @@ def batch_to_arr2d(batches, names): def ceil_to_batch_size(num, batch_size): - return int(batch_size * ceil(num/batch_size)) + """Calculate how many full batches in num. + + Parameters + ---------- + num : int + batch_size : int + + """ + return int(batch_size * ceil(num / batch_size)) def normalize_weights(weights): + """Normalize weights to sum to unity.""" w = np.atleast_1d(weights) if np.any(w < 0): raise ValueError("Weights must be positive") wsum = np.sum(weights) if wsum == 0: raise ValueError("All weights are zero") - return w/wsum + return w / wsum def weighted_var(x, weights=None): @@ -126,16 +135,16 @@ def pdf(cls, x, means, cov=1, weights=None): Parameters ---------- x : array_like - scalar, 1d or 2d array of points where to evaluate, observations in rows + Scalar, 1d or 2d array of points where to evaluate, observations in rows means : array_like - means of the Gaussian mixture components. It is assumed that means[0] contains + 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, float - a shared covariance matrix for the mixture components - """ + A shared covariance matrix for the mixture components + """ means, weights = cls._normalize_params(means, weights) ndim = np.asanyarray(x).ndim @@ -149,43 +158,94 @@ def pdf(cls, x, means, cov=1, weights=None): d += w * ss.multivariate_normal.pdf(x, mean=m, cov=cov) # Cast to correct ndim - if ndim == 0 or (ndim==1 and means.ndim==2): + if ndim == 0 or (ndim == 1 and means.ndim == 2): return d.squeeze() 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): - """Random variates from the distribution + """Evaluate the log density at points x. 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 - size : int or tuple - random_state : np.random.RandomState or None + cov : array_like, float + A shared covariance matrix for the mixture components + """ + return np.log(cls.pdf(x, means=means, cov=cov, weights=weights)) - means, weights = cls._normalize_params(means, weights) + @classmethod + def rvs(cls, means, cov=1, weights=None, size=1, prior_logpdf=None, random_state=None): + """Draw random variates from the distribution. + + Parameters + ---------- + means : array_like + Means of the Gaussian mixture components + cov : array_like, optional + A shared covariance matrix for the mixture components + weights : array_like, optional + 1d array of weights of the gaussian mixture components + size : int or tuple or None, optional + Number or shape of samples to draw (a single sample has the shape of `means`). + If None, return one sample without an enclosing array. + prior_logpdf : callable, optional + Can be used to check validity of random variable. + random_state : np.random.RandomState, optional + + """ random_state = random_state or np.random + means, weights = cls._normalize_params(means, weights) - inds = random_state.choice(len(means), size=size, p=weights) - rvs = means[inds] - perturb = ss.multivariate_normal.rvs(mean=means[0]*0, - cov=cov, - random_state=random_state, - size=size) - return rvs + perturb + if size is None: + size = 1 + no_wrap = True + else: + no_wrap = False + + output = np.empty((size,) + means.shape[1:]) + + n_accepted = 0 + n_left = size + trials = 0 + + while n_accepted < size: + inds = random_state.choice(len(means), size=n_left, p=weights) + rvs = means[inds] + perturb = ss.multivariate_normal.rvs(mean=means[0] * 0, + cov=cov, + random_state=random_state, + size=n_left) + x = rvs + perturb + + # check validity of x + if prior_logpdf is not None: + x = x[np.isfinite(prior_logpdf(x))] + + n_accepted1 = len(x) + output[n_accepted: n_accepted+n_accepted1] = x + n_accepted += n_accepted1 + n_left -= n_accepted1 + + trials += 1 + if trials == 100: + logger.warning("SMC: It appears to be difficult to find enough valid proposals " + "with prior pdf > 0. ELFI will keep trying, but you may wish " + "to kill the process and adjust the model priors.") + + logger.debug('Needed %i trials to find %i valid samples.', trials, size) + if no_wrap: + return output[0] + else: + return output @staticmethod def _normalize_params(means, weights): @@ -216,19 +276,19 @@ def numgrad(fn, x, h=None, replace_neg_inf=True): ------- 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)) + 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 + np.fill_diagonal(Xi, Xi.diagonal() + (i - 1) * h) + X[i * dim:(i + 1) * dim, :] = Xi f = fn(X) f = f.reshape((3, dim)) @@ -242,18 +302,20 @@ def numgrad(fn, x, h=None, replace_neg_inf=True): # 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 +# 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: could use some optimization class ModelPrior: - """Constructs a joint prior distribution over all the parameter nodes in `ElfiModel`""" + """Construct a joint prior distribution over all the parameter nodes in `ElfiModel`.""" def __init__(self, model): - """ + """Initialize a ModelPrior. Parameters ---------- model : ElfiModel + """ model = model.copy() self.parameter_names = model.parameter_names @@ -269,10 +331,16 @@ def __init__(self, model): 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 - context = ComputationContext(size or 1, get_sub_seed(random_state, 0)) + """Sample the joint prior.""" + random_state = np.random if random_state is None else random_state + context = ComputationContext(size or 1, seed='global') loaded_net = self.client.load_data(self._rvs_net, context, batch_index=0) + + # Change to the correct random_state instance + # TODO: allow passing random_state to ComputationContext seed + loaded_net.node['_random_state'] = {'output': random_state} + batch = self.client.compute(loaded_net) rvs = np.column_stack([batch[p] for p in self.parameter_names]) @@ -282,9 +350,11 @@ def rvs(self, size=None, random_state=None): return rvs[0] if size is None else rvs def pdf(self, x): + """Return the density of the joint prior at x.""" return self._evaluate_pdf(x) def logpdf(self, x): + """Return the log density of the joint prior at x.""" return self._evaluate_pdf(x, log=True) def _evaluate_pdf(self, x, log=False): @@ -306,29 +376,28 @@ def _evaluate_pdf(self, x, log=False): loaded_net = self.client.load_data(net, context, batch_index=0) # Override - for k, v in batch.items(): loaded_net.node[k] = {'output': v} + 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): + if ndim == 0 or (ndim == 1 and self.dim > 1): val = val[0] return val def gradient_pdf(self, x): + """Return the gradient of density of the joint prior at x.""" raise NotImplementedError def gradient_logpdf(self, x, stepsize=None): - """ + """Return the gradient of log density of the joint prior at x. Parameters ---------- - x + x : float or np.ndarray stepsize : float or list Stepsize or stepsizes for the dimensions - Returns - ------- - """ x = np.asanyarray(x) ndim = x.ndim @@ -343,7 +412,7 @@ def gradient_logpdf(self, x, stepsize=None): grads[np.isinf(grads)] = 0 grads[np.isnan(grads)] = 0 - if ndim == 0 or (ndim==1 and self.dim > 1): + if ndim == 0 or (ndim == 1 and self.dim > 1): grads = grads[0] return grads diff --git a/elfi/model/__init__.py b/elfi/model/__init__.py index e69de29b..6e031999 100644 --- a/elfi/model/__init__.py +++ b/elfi/model/__init__.py @@ -0,0 +1 @@ +# noqa: D104 diff --git a/elfi/model/augmenter.py b/elfi/model/augmenter.py index 142ada5a..21755436 100644 --- a/elfi/model/augmenter.py +++ b/elfi/model/augmenter.py @@ -1,6 +1,7 @@ -import functools +"""This module contains auxiliary functions for augmenting the ELFI graph.""" + from functools import partial, reduce -from operator import mul, add +from operator import add, mul from toolz.functoolz import compose @@ -9,7 +10,9 @@ def add_pdf_gradient_nodes(model, log=False, nodes=None): - """Adds gradient nodes for distribution nodes to the model and returns the node names. + """Add gradient nodes for distribution nodes to the model. + + Returns the node names. By default this gives the pdfs of the generated model parameters. @@ -27,7 +30,6 @@ def add_pdf_gradient_nodes(model, log=False, nodes=None): List of gradient node names. """ - nodes = nodes or model.parameter_names gradattr = 'gradient_pdf' if log is False else 'gradient_logpdf' @@ -38,7 +40,9 @@ def add_pdf_gradient_nodes(model, log=False, nodes=None): # 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. + """Add pdf nodes for distribution nodes to the model. + + Returns the node names. By default this gives the pdfs of the generated model parameters. @@ -78,13 +82,13 @@ def _add_distribution_nodes(model, nodes, attr): 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))) + 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 + """Reduce the output from a collection of nodes. Parameters ---------- @@ -99,9 +103,10 @@ def add_reduce_node(model, nodes, reduce_operation, name): ------- 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 + op = Operation( + compose(partial(reduce, reduce_operation), args_to_tuple), *nodes, model=model, name=name) + return op.name diff --git a/elfi/model/elfi_model.py b/elfi/model/elfi_model.py index 2bcb08f8..c08f804d 100644 --- a/elfi/model/elfi_model.py +++ b/elfi/model/elfi_model.py @@ -1,66 +1,122 @@ -import logging -import copy +"""This module contains classes for creating ELFI graphs (`ElfiModel`). + +The ElfiModel is a directed acyclic graph (DAG), whose nodes represent +parts of the inference task, for example the parameters to be inferred, +the simulator or a summary statistic. + +https://en.wikipedia.org/wiki/Directed_acyclic_graph +""" + import inspect +import logging +import os +import pickle import re import uuid from functools import partial -import logging import scipy.spatial import elfi.client from elfi.model.graphical_model import GraphicalModel -from elfi.model.utils import rvs_from_distribution, distance_as_discrepancy +from elfi.model.utils import distance_as_discrepancy, rvs_from_distribution from elfi.store import OutputPool -from elfi.utils import scipy_from_str, observed_name, random_seed - -logger = logging.getLogger(__name__) - -__all__ = ['ElfiModel', 'ComputationContext', 'NodeReference', - 'Constant', 'Operation', 'RandomVariable', - 'Prior', 'Simulator', 'Summary', 'Discrepancy', 'Distance', - 'get_current_model', 'set_current_model', 'new_model'] +from elfi.utils import observed_name, random_seed, scipy_from_str +__all__ = [ + 'ElfiModel', 'ComputationContext', 'NodeReference', 'Constant', 'Operation', 'RandomVariable', + 'Prior', 'Simulator', 'Summary', 'Discrepancy', 'Distance', 'get_default_model', + 'set_default_model', 'new_model', 'load_model' +] logger = logging.getLogger(__name__) +_default_model = None -"""This module contains the classes for creating generative models in ELFI. The class that -describes the generative model is named `ElfiModel`.""" +def get_default_model(): + """Return the current default ``ElfiModel`` instance. - -_current_model = None + New nodes will be added to this model by default. + """ + global _default_model + if _default_model is None: + _default_model = ElfiModel() + return _default_model -def get_current_model(): - """Return the current default `elfi.ElfiModel` instance. +def set_default_model(model=None): + """Set the current default ``ElfiModel`` instance. - New nodes will be added to this model by default. - """ - global _current_model - if _current_model is None: - _current_model = ElfiModel() - return _current_model + New nodes will be placed the given model by default. + Parameters + ---------- + model : ElfiModel, optional + If None, creates a new ``ElfiModel``. -def set_current_model(model=None): - """Set the current default `elfi.ElfiModel` instance.""" - global _current_model + """ + global _default_model if model is None: model = ElfiModel() if not isinstance(model, ElfiModel): raise ValueError('{} is not an instance of ElfiModel'.format(ElfiModel)) - _current_model = model + _default_model = model + +def new_model(name=None, set_default=True): + """Create a new ``ElfiModel`` instance. -def new_model(name=None, set_current=True): + In addition to making a new ElfiModel instance, this method sets the new instance as + the default for new nodes. + + Parameters + ---------- + name : str, optional + set_default : bool, optional + Whether to set the newly created model as the current model. + + """ model = ElfiModel(name=name) - if set_current: - set_current_model(model) + if set_default: + set_default_model(model) + return model + + +def load_model(name, prefix=None, set_default=True): + """Load the pickled ElfiModel. + + Assumes there exists a file "name.pkl" in the current directory. Also sets the loaded + model as the default model for new nodes. + + Parameters + ---------- + name : str + Name of the model file to load (without the .pkl extension). + prefix : str + Path to directory where the model file is located, optional. + set_default : bool, optional + Set the loaded model as the default model. Default is True. + + Returns + ------- + ElfiModel + + """ + model = ElfiModel.load(name, prefix=prefix) + if set_default: + set_default_model(model) return model def random_name(length=4, prefix=''): + """Generate a random string. + + Parameters + ---------- + length : int, optional + prefix : str, optional + + """ return prefix + str(uuid.uuid4().hex[0:length]) @@ -72,28 +128,31 @@ class ComputationContext: ---------- seed : int batch_size : int - pool : elfi.OutputPool + pool : OutputPool num_submissions : int Number of submissions using this context. + sub_seed_cache : dict + Caches the sub seed generation state variables. This is Notes ----- The attributes are immutable. """ + def __init__(self, batch_size=None, seed=None, pool=None): - """ + """Set up a ComputationContext. Parameters ---------- - batch_size : int - seed : int, None, 'global' + batch_size : int, optional + seed : int, None, 'global', optional When None generates a random integer seed. When `'global'` uses the global - numpy random state. Only recommended for debugging - pool : elfi.OutputPool + numpy random state. Only recommended for debugging. + pool : elfi.OutputPool, optional + Used for storing output. """ - # Check pool context if pool is not None and pool.has_context: if batch_size is None: @@ -108,6 +167,7 @@ def __init__(self, batch_size=None, seed=None, pool=None): self._batch_size = batch_size or 1 self._seed = random_seed() if seed is None else seed + self.sub_seed_cache = {} self._pool = pool # Count the number of submissions from this context @@ -118,26 +178,42 @@ def __init__(self, batch_size=None, seed=None, pool=None): @property def pool(self): + """Return the output pool.""" return self._pool @property def batch_size(self): + """Return the batch size.""" return self._batch_size @property def seed(self): + """Return the random seed.""" return self._seed def callback(self, batch, batch_index): + """Add the batch to pool. + + Parameters + ---------- + batch : dict + batch_index : int + + """ if self._pool is not None: self._pool.add_batch(batch, batch_index) class ElfiModel(GraphicalModel): - """A generative model for LFI + """A container for the inference model. + + The ElfiModel is a directed acyclic graph (DAG), whose nodes represent + parts of the inference task, for example the parameters to be inferred, + the simulator or a summary statistic. """ + def __init__(self, name=None, observed=None, source_net=None): - """ + """Initialize the inference model. Parameters ---------- @@ -146,31 +222,31 @@ def __init__(self, name=None, observed=None, source_net=None): Observed data with node names as keys. source_net : nx.DiGraph, optional set_current : bool, optional - Sets this model as the current ELFI model - """ + Sets this model as the current (default) ELFI model + """ super(ElfiModel, self).__init__(source_net) self.name = name or "model_{}".format(random_name()) self.observed = observed or {} @property def name(self): - """Name of the model""" + """Return name of the model.""" return self.source_net.graph['name'] @name.setter def name(self, name): - """Sets the name of the model""" + """Set the name of the model.""" self.source_net.graph['name'] = name @property def observed(self): - """The observed data for the nodes in a dictionary.""" + """Return the observed data for the nodes in a dictionary.""" return self.source_net.graph['observed'] @observed.setter def observed(self, observed): - """Set the observed data of the model + """Set the observed data of the model. Parameters ---------- @@ -183,19 +259,18 @@ def observed(self, observed): self.source_net.graph['observed'] = observed def generate(self, batch_size=1, outputs=None, with_values=None): - """Generates a batch of outputs using the global seed. + """Generate a batch of outputs using the global numpy seed. - This method is useful for testing that the generative model works. + This method is useful for testing that the ELFI graph works. Parameters ---------- - batch_size : int - outputs : list - with_values : dict + batch_size : int, optional + outputs : list, optional + with_values : dict, optional You can specify values for nodes to use when generating data """ - if outputs is None: outputs = self.source_net.nodes() elif isinstance(outputs, str): @@ -215,16 +290,28 @@ def generate(self, batch_size=1, outputs=None, with_values=None): return client.compute(loaded_net) def get_reference(self, name): - """Returns a new reference object for a node in the model.""" + """Return a new reference object for a node in the model. + + Parameters + ---------- + name : str + + """ cls = self.get_node(name)['_class'] return cls.reference(name, self) def get_state(self, name): - """Return the state of the node.""" + """Return the state of the node. + + Parameters + ---------- + name : str + + """ return self.source_net.node[name] def update_node(self, name, updating_name): - """Updates `node` with `updating_node` in the model. + """Update `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 @@ -234,8 +321,8 @@ def update_node(self, name, updating_name): ---------- name : str updating_name : str - """ + """ update_observed = False obs = None if updating_name in self.observed: @@ -249,7 +336,7 @@ def update_node(self, name, updating_name): self.observed[name] = obs def remove_node(self, name): - """Remove a node from the graph + """Remove a node from the graph. Parameters ---------- @@ -262,24 +349,21 @@ def remove_node(self, name): @property def parameter_names(self): - """A list of model parameter names in an alphabetical order.""" + """Return 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)]) @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 parameter node. Other nodes will be marked as not being parameter nodes. - + Parameters ---------- parameter_names : list A list of parameter names - - Returns - ------- - None + """ parameter_names = set(parameter_names) for n in self.nodes: @@ -288,12 +372,13 @@ def parameter_names(self, parameter_names): parameter_names.remove(n) state['_parameter'] = True else: - if '_parameter' in state: state.pop('_parameter') + if '_parameter' in state: + state.pop('_parameter') if len(parameter_names) > 0: raise ValueError('Parameters {} not found from the model'.format(parameter_names)) def copy(self): - """Return a copy of the ElfiModel instance + """Return a copy of the ElfiModel instance. Returns ------- @@ -304,7 +389,53 @@ def copy(self): kopy.name = "{}_copy_{}".format(self.name, random_name()) return kopy + def save(self, prefix=None): + """Save the current model to pickled file. + + Parameters + ---------- + prefix : str, optional + Path to the directory under which to save the model. Default is the current working + directory. + + """ + path = self.name + '.pkl' + if prefix is not None: + os.makedirs(prefix, exist_ok=True) + path = os.path.join(prefix, path) + pickle.dump(self, open(path, "wb")) + + @classmethod + def load(cls, name, prefix): + """Load the pickled ElfiModel. + + Assumes there exists a file "name.pkl" in the current directory. + + Parameters + ---------- + name : str + Name of the model file to load (without the .pkl extension). + prefix : str + Path to directory where the model file is located, optional. + + Returns + ------- + ElfiModel + + """ + path = name + '.pkl' + if prefix is not None: + path = os.path.join(prefix, path) + return pickle.load(open(path, "rb")) + def __getitem__(self, node_name): + """Return a new reference object for a node in the model. + + Parameters + ---------- + node_name : str + + """ return self.get_reference(node_name) @@ -330,7 +461,7 @@ class NodeReference(InstructionsMapper): 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 - `ElfiModel` so that serializing the model is straightforward. `NodeReference` and it's + `ElfiModel` so that serializing the model is straightforward. `NodeReference` and its subclasses are convenience classes that make it easy to manipulate the state. They only contain a reference to the corresponding state in the `ElfiModel`. @@ -348,17 +479,18 @@ class NodeReference(InstructionsMapper): `self.model.source_net`. """ + def __init__(self, *parents, state=None, model=None, name=None): - """ + """Initialize a NodeReference. Parameters ---------- - parents : variable - name : string + parents : variable, optional + name : string, optional If name ends in an asterisk '*' character, the asterisk will be replaced with a random string and the name is ensured to be unique within the model. - state : dict - model : elfi.ElfiModel + state : dict, optional + model : elfi.ElfiModel, optional Examples -------- @@ -397,24 +529,25 @@ def _determine_model(self, model, parents): raise ValueError('Parents are from different models!') if model is None: - model = get_current_model() + model = get_default_model() return model @property def parents(self): - """Get all the positional parent nodes (inputs) of this node + """Get all positional parent nodes (inputs) of this node. Returns ------- parents : list List of positional parents + """ return [self.model[p] for p in self.model.get_parents(self.name)] @classmethod def reference(cls, name, model): - """Constructor for creating a reference for an existing node in the model + """Construct a reference for an existing node in the model. Parameters ---------- @@ -425,6 +558,7 @@ def reference(cls, name, model): Returns ------- NodePointer instance + """ instance = cls.__new__(cls) instance._init_reference(name, model) @@ -455,7 +589,7 @@ def become(self, other_node): other_node.model = self.model def _init_reference(self, name, model): - """Initializes all internal variables of the instance + """Initialize all internal variables of the instance. Parameters ---------- @@ -467,14 +601,14 @@ def _init_reference(self, name, model): self.model = model def generate(self, batch_size=1, with_values=None): - """Generates output from this node. + """Generate output from this node. Useful for testing. Parameters ---------- - batch_size : int - with_values : dict + batch_size : int, optional + with_values : dict, optional """ result = self.model.generate(batch_size, self.name, with_values=with_values) @@ -489,7 +623,7 @@ def _give_name(self, name, model): try: name = self._inspect_name() - except: + except BaseException: logger.warning("Automatic name inspection failed, using a random name " "instead. This may be caused by using an interactive Python " "shell. You can provide a name parameter e.g. " @@ -503,10 +637,10 @@ def _give_name(self, name, model): return name def _inspect_name(self): - """Magic method in trying to infer the name from the code. - - Does not work in interactive python shell.""" + """Magic method that tries to infer the name from the code. + Does not work in interactive python shell. + """ # Test if context info is available and try to give the same name as the variable # Please note that this is only a convenience method which is not guaranteed to # work in all cases. If you require a specific name, pass the name argument. @@ -540,39 +674,40 @@ def _new_name(self, basename='', model=None): basename = '_{}'.format(self.__class__.__name__.lower()) while True: name = "{}_{}".format(basename, random_name()) - if not model.has_node(name): break + if not model.has_node(name): + break return name @property def state(self): - """State dictionary of the node""" + """Return the state dictionary of the node.""" if self.model is None: - raise ValueError('{} {} is not initialized'.format(self.__class__.__name__, - self.name)) + raise ValueError('{} {} is not initialized'.format(self.__class__.__name__, self.name)) return self.model.get_node(self.name) def __getitem__(self, item): - """Get item from the state dict of the node - """ + """Get item from the state dict of the node.""" return self.state[item] def __setitem__(self, item, value): - """Set item into the state dict of the node - """ + """Set item into the state dict of the node.""" self.state[item] = value def __repr__(self): + """Return a representation comprised of the names of the class and the node.""" return "{}(name='{}')".format(self.__class__.__name__, self.name) def __str__(self): + """Return the name of the node.""" return self.name class StochasticMixin(NodeReference): - """Makes a node stochastic + """Define the inheriting node as 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 @@ -580,7 +715,7 @@ def __init__(self, *parents, state, **kwargs): class ObservableMixin(NodeReference): - """Makes a node observable + """Define the inheriting node as 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 @@ -608,15 +743,32 @@ def observed(self): class Constant(NodeReference): """A node holding a constant value.""" + def __init__(self, value, **kwargs): + """Initialize a node holding a constant value. + + Parameters + ---------- + value + The constant value of the node. + + """ state = dict(_output=value) super(Constant, self).__init__(state=state, **kwargs) class Operation(NodeReference): - """A generic deterministic operation node. - """ + """A generic deterministic operation node.""" + def __init__(self, fn, *parents, **kwargs): + """Initialize a node that performs an operation. + + Parameters + ---------- + fn : callable + The operation of the node. + + """ state = dict(_operation=fn) super(Operation, self).__init__(*parents, state=state, **kwargs) @@ -625,7 +777,7 @@ class RandomVariable(StochasticMixin, NodeReference): """A node that draws values from a random distribution.""" def __init__(self, distribution, *params, size=None, **kwargs): - """ + """Initialize a node that represents a random variable. Parameters ---------- @@ -635,15 +787,19 @@ def __init__(self, distribution, *params, size=None, **kwargs): Output size of a single random draw. """ - - state = dict(distribution=distribution, - size=size, - _uses_batch_size=True) + state = dict(distribution=distribution, size=size, _uses_batch_size=True) state['_operation'] = self.compile_operation(state) super(RandomVariable, self).__init__(*params, state=state, **kwargs) @staticmethod def compile_operation(state): + """Compile a callable operation that samples the associated distribution. + + Parameters + ---------- + state : dict + + """ size = state['size'] distribution = state['distribution'] if not (size is None or isinstance(size, tuple)): @@ -656,15 +812,14 @@ def compile_operation(state): distribution = scipy_from_str(distribution) if not hasattr(distribution, 'rvs'): - raise ValueError("Distribution {} " - "must implement a rvs method".format(distribution)) + raise ValueError("Distribution {} " "must implement a rvs method".format(distribution)) op = partial(rvs_from_distribution, distribution=distribution, size=size) return op @property def distribution(self): - """Returns the distribution object.""" + """Return the distribution object.""" distribution = self['distribution'] if isinstance(distribution, str): distribution = scipy_from_str(distribution) @@ -672,10 +827,11 @@ def distribution(self): @property def size(self): - """Returns the size of the output from the distribution.""" + """Return the size of the output from the distribution.""" return self['size'] def __repr__(self): + """Return a string representation of the node.""" d = self.distribution if isinstance(d, str): @@ -691,9 +847,10 @@ def __repr__(self): class Prior(RandomVariable): - """A parameter node of a generative model.""" + """A parameter node of an ELFI graph.""" + def __init__(self, distribution, *params, size=None, **kwargs): - """ + """Initialize a Prior. Parameters ---------- @@ -726,12 +883,13 @@ def __init__(self, distribution, *params, size=None, **kwargs): class Simulator(StochasticMixin, ObservableMixin, NodeReference): - """A simulator node of a generative model. + """A simulator node of an ELFI graph. Simulator nodes are stochastic and may have observed data in the model. """ + def __init__(self, fn, *params, **kwargs): - """ + """Initialize a Simulator. Parameters ---------- @@ -740,19 +898,21 @@ def __init__(self, fn, *params, **kwargs): 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. + """A summary node of an ELFI graph. 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): - """ + """Initialize a Summary. Parameters ---------- @@ -761,6 +921,7 @@ def __init__(self, fn, *parents, **kwargs): parents Input data for the summary function. kwargs + """ if not parents: raise ValueError('This node requires that at least one parent is specified.') @@ -769,12 +930,13 @@ def __init__(self, fn, *parents, **kwargs): class Discrepancy(NodeReference): - """A discrepancy node of a generative model. + """A discrepancy node of an ELFI graph. This class provides a convenience node for custom distance operations. """ + def __init__(self, discrepancy, *parents, **kwargs): - """ + """Initialize a Discrepancy. Parameters ---------- @@ -802,8 +964,10 @@ def __init__(self, discrepancy, *parents, **kwargs): # TODO: add weights class Distance(Discrepancy): + """A convenience class for the discrepancy node.""" + def __init__(self, distance, *summaries, p=None, w=None, V=None, VI=None, **kwargs): - """A distance node of a generative model. + """Initialize a distance node of an ELFI graph. This class contains many common distance implementations through scipy. @@ -843,7 +1007,8 @@ def __init__(self, distance, *summaries, p=None, w=None, V=None, VI=None, **kwar 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 + Scipy distances: + https://docs.scipy.org/doc/scipy/reference/generated/generated/scipy.spatial.distance.cdist.html # noqa See Also -------- diff --git a/elfi/model/extensions.py b/elfi/model/extensions.py index 95972dbc..23b8ffdf 100644 --- a/elfi/model/extensions.py +++ b/elfi/model/extensions.py @@ -1,32 +1,37 @@ +"""Extensions: ScipyLikeDistribution.""" + 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. - + """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. + 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): - """Constuctor (optional, only if instances are meant to be used) + """Constuctor (optional, only if instances are meant to be used). Parameters ---------- - name : name of the distribution + name : str + Name of the distribution. + """ self._name = name or self.__class__.__name__ @classmethod def rvs(this, *params, size=1, random_state): - """Random variates + """Generate random variates. Parameters ---------- @@ -39,12 +44,13 @@ def rvs(this, *params, size=1, random_state): ------- rvs : ndarray Random variates of given size. + """ raise NotImplementedError @classmethod def pdf(this, x, *params, **kwargs): - """Probability density function at x + """Probability density function at x. Parameters ---------- @@ -57,6 +63,7 @@ def pdf(this, x, *params, **kwargs): ------- pdf : ndarray Probability density function evaluated at x + """ raise NotImplementedError @@ -67,15 +74,16 @@ def logpdf(this, x, *params, **kwargs): Parameters ---------- x : array_like - points where to evaluate the logpdf + Points where to evaluate the logpdf. param1, param2, ... : array_like - parameters of the model + Parameters of the model. kwargs Returns ------- logpdf : ndarray - Log of the probability density function evaluated at x + Log of the probability density function evaluated at x. + """ p = this.pdf(x, *params, **kwargs) @@ -87,6 +95,7 @@ def logpdf(this, x, *params, **kwargs): @property def name(this): + """Return the name of the distribution.""" if hasattr(this, '_name'): return this._name elif isinstance(this, type): diff --git a/elfi/model/graphical_model.py b/elfi/model/graphical_model.py index 6ff000b3..a96eb659 100644 --- a/elfi/model/graphical_model.py +++ b/elfi/model/graphical_model.py @@ -1,21 +1,38 @@ +"""This module contains an interface between ELFI and NetworkX.""" + from operator import itemgetter import networkx as nx class GraphicalModel: - """ - Network class for the ElfiModel. - """ + """Network class for the ElfiModel.""" + def __init__(self, source_net=None): + """Initialize the graph. + + Parameters + ---------- + source_net : nx.DiGraph, optional + + """ self.source_net = source_net or nx.DiGraph() def add_node(self, name, state): + """Add node `name` to the graph. + + Parameters + ---------- + name : str + state : dict + + """ if self.has_node(name): raise ValueError('Node {} already exists'.format(name)) self.source_net.add_node(name, attr_dict=state) def remove_node(self, name): + """Remove node 'name' from the graph.""" parent_names = self.get_parents(name) self.source_net.remove_node(name) @@ -26,26 +43,38 @@ def remove_node(self, name): self.remove_node(p) def get_node(self, name): - """Returns the state of the node + """Return the state of the node. Returns ------- out : dict + """ return self.source_net.node[name] def set_node(self, name, state): - """Set the state of the node""" + """Set the state of the node.""" self.source_net.node[name] = state def has_node(self, name): + """Whether the graph has a node `name`.""" return self.source_net.has_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 + """Add an edge between nodes. + + Deprecated. By default, map to a positional parameter of the child. + + Parameters + ---------- + parent_name : str + child_name : str + param_name : str or int + + """ if param_name is None: param_name = len(self.get_parents(child_name)) if not isinstance(param_name, (int, str)): @@ -61,8 +90,8 @@ 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 the model. - + """Update `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. @@ -70,8 +99,8 @@ def update_node(self, node, updating_node): ---------- node : str updating_node : str - """ + """ out_edges = self.source_net.out_edges(node, data=True) self.remove_node(node) self.source_net.add_node(node, self.source_net.node[updating_node]) @@ -84,7 +113,7 @@ def update_node(self, node, updating_node): self.remove_node(updating_node) def get_parents(self, child_name): - """ + """Return the names of parents of node `child_name`. Parameters ---------- @@ -94,6 +123,7 @@ def get_parents(self, child_name): ------- parent_names : list List of positional parent names + """ args = [] for parent_name in self.source_net.predecessors(child_name): @@ -104,14 +134,16 @@ def get_parents(self, child_name): @property def nodes(self): - """Returns a list of nodes""" + """Return a list of nodes.""" return self.source_net.nodes() def copy(self): + """Return a copy of the graph.""" kopy = self.__class__() # Copy the source net kopy.source_net = nx.DiGraph(self.source_net) return kopy def __copy__(self, *args, **kwargs): + """Return a copy of the graph.""" return self.copy() diff --git a/elfi/model/tools.py b/elfi/model/tools.py index 98ab2d9f..c0aab4b7 100644 --- a/elfi/model/tools.py +++ b/elfi/model/tools.py @@ -1,18 +1,17 @@ +"""This module contains tools for ELFI graphs.""" + import subprocess -import warnings from functools import partial import numpy as np from elfi.utils import get_sub_seed, is_array - __all__ = ['vectorize', 'external_operation'] -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. +def run_vectorized(operation, *inputs, constants=None, dtype=None, batch_size=None, **kwargs): + """Run 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 not work in all cases. @@ -34,8 +33,8 @@ def run_vectorized(operation, *inputs, constants=None, dtype=None, batch_size=No ------- operation_output If batch_size > 1, a numpy array of outputs is returned - """ + """ constants = [] if constants is None else list(constants) # Check input and set constants and batch_size if needed @@ -95,7 +94,7 @@ def run_vectorized(operation, *inputs, constants=None, dtype=None, batch_size=No def vectorize(operation, constants=None, dtype=None): - """Vectorizes an operation. + """Vectorize 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. @@ -140,6 +139,7 @@ def vectorize(operation, constants=None, dtype=None): def unpack_meta(*inputs, **kwinputs): + """Update ``kwinputs`` with keys and values from its ``meta`` dictionary.""" if 'meta' in kwinputs: new_kwinputs = kwinputs['meta'].copy() new_kwinputs.update(kwinputs) @@ -149,6 +149,7 @@ def unpack_meta(*inputs, **kwinputs): def prepare_seed(*inputs, **kwinputs): + """Update ``kwinputs`` with the seed from its value ``random_state``.""" if 'random_state' in kwinputs: # Get the seed for this batch, assuming np.RandomState instance seed = kwinputs['random_state'].get_state()[1][0] @@ -156,18 +157,23 @@ def prepare_seed(*inputs, **kwinputs): # Since we may not be the first operation to use this seed, lets generate a # a sub seed using this seed sub_seed_index = kwinputs.get('index_in_batch') or 0 - kwinputs['seed'] = get_sub_seed(np.random.RandomState(seed), sub_seed_index) + kwinputs['seed'] = get_sub_seed(seed, sub_seed_index) return inputs, kwinputs def stdout_to_array(stdout, *inputs, sep=' ', dtype=None, **kwinputs): - """Converts a single row from stdout to np.array""" + """Convert a single row from stdout to np.array.""" return np.fromstring(stdout, dtype=dtype, sep=sep) -def run_external(command, *inputs, process_result=None, prepare_inputs=None, - stdout=True, subprocess_kwargs=None, **kwinputs): +def run_external(command, + *inputs, + process_result=None, + prepare_inputs=None, + stdout=True, + subprocess_kwargs=None, + **kwinputs): """Run an external commmand (e.g. shell script, or executable) on a subprocess. See external_operation below for parameter descriptions. @@ -175,8 +181,8 @@ def run_external(command, *inputs, process_result=None, prepare_inputs=None, Returns ------- output - """ + """ inputs, kwinputs = unpack_meta(*inputs, **kwinputs) inputs, kwinputs = prepare_seed(*inputs, **kwinputs) if prepare_inputs: @@ -203,12 +209,16 @@ def run_external(command, *inputs, process_result=None, prepare_inputs=None, return output -def external_operation(command, process_result=None, prepare_inputs=None, sep=' ', - stdout=True, subprocess_kwargs=None): +def external_operation(command, + process_result=None, + prepare_inputs=None, + sep=' ', + stdout=True, + subprocess_kwargs=None): """Wrap an external command as a Python callable (function). - + The external command can be e.g. a shell script, or an executable file. - + Parameters ---------- command : str @@ -238,10 +248,9 @@ def external_operation(command, process_result=None, prepare_inputs=None, sep=' Options for Python's `subprocess.run` that is used to run the external command. Defaults are `shell=True, check=True`. See the `subprocess` documentation for more details. - + Examples -------- - >>> import elfi >>> op = elfi.tools.external_operation('echo 1 {0}', process_result='int8') >>> @@ -249,13 +258,13 @@ def external_operation(command, process_result=None, prepare_inputs=None, sep=' >>> simulator = elfi.Simulator(op, constant) >>> simulator.generate() array([ 1, 123], dtype=int8) - + Returns ------- operation : callable ELFI compatible operation that can be used e.g. as a simulator. - """ + """ if process_result is None or isinstance(process_result, (str, np.dtype)): fromstring_kwargs = dict(sep=sep) if isinstance(process_result, (str, np.dtype)): @@ -268,6 +277,10 @@ def external_operation(command, process_result=None, prepare_inputs=None, sep=' subprocess_kwargs = subprocess_kwargs or {} subprocess_kwargs['stdout'] = subprocess.PIPE - return partial(run_external, command, process_result=process_result, - prepare_inputs=prepare_inputs, stdout=stdout, - subprocess_kwargs=subprocess_kwargs) + return partial( + run_external, + command, + process_result=process_result, + prepare_inputs=prepare_inputs, + stdout=stdout, + subprocess_kwargs=subprocess_kwargs) diff --git a/elfi/model/utils.py b/elfi/model/utils.py index 1e891c2b..9b789db6 100644 --- a/elfi/model/utils.py +++ b/elfi/model/utils.py @@ -1,8 +1,10 @@ +"""Utilities for ElfiModels.""" + import numpy as np def rvs_from_distribution(*params, batch_size, distribution, size=None, random_state=None): - """Transforms a scipy like distribution to an elfi operation + """Transform the rvs method of a scipy like distribution to an operation in ELFI. Parameters ---------- @@ -23,7 +25,6 @@ def rvs_from_distribution(*params, batch_size, distribution, size=None, random_s Used internally by the RandomVariable to wrap distributions for the framework. """ - if size is None: size = (batch_size, ) else: @@ -34,6 +35,7 @@ def rvs_from_distribution(*params, batch_size, distribution, size=None, random_s def distance_as_discrepancy(dist, *summaries, observed): + """Evaluate a distance function with signature `dist(summaries, observed)` in ELFI.""" summaries = np.column_stack(summaries) # Ensure observed are 2d observed = np.concatenate([np.atleast_2d(o) for o in observed], axis=1) @@ -45,5 +47,6 @@ def distance_as_discrepancy(dist, *summaries, observed): 'have to be at most 2d. Especially ensure that summary nodes ' 'outputs 2d data even with batch_size=1. Original error message ' 'was: {}'.format(e)) - if d.ndim == 2 and d.shape[1] == 1: d = d.reshape(-1) + if d.ndim == 2 and d.shape[1] == 1: + d = d.reshape(-1) return d diff --git a/elfi/store.py b/elfi/store.py index 47ecfa0f..a1a21302 100644 --- a/elfi/store.py +++ b/elfi/store.py @@ -1,15 +1,16 @@ +"""This module contains implementations for storing simulated values for later use.""" + +import io import logging import os -import io -import shutil import pickle +import shutil import numpy as np import numpy.lib.format as npformat logger = logging.getLogger(__name__) - _default_prefix = 'pools' @@ -34,30 +35,30 @@ class OutputPool: _pkl_name = '_outputpool.pkl' def __init__(self, outputs=None, name=None, prefix=None): - """ + """Initialize OutputPool. 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 + after making some changes to `ElfiModel` thus speeding up the inference + significantly. For instance, if all the simulations are stored in Rejection sampling, one can change the summaries and distances without having to rerun the simulator. - + Parameters ---------- outputs : list, dict, optional - list of node names which to store or a dictionary with existing stores. The + List of node names which to store or a dictionary with existing stores. The stores are created on demand. name : str, optional Name of the pool. Used to open a saved pool from disk. prefix : str, optional Path to directory under which `elfi.ArrayPool` will place its folder. Default is a relative path ./pools. - + Returns ------- instance : OutputPool - """ + """ if outputs is None: stores = {} elif isinstance(outputs, dict): @@ -79,28 +80,27 @@ def __init__(self, outputs=None, name=None, prefix=None): @property def output_names(self): + """Return a list of stored names.""" return list(self.stores.keys()) @property def has_context(self): + """Check if current pool has context information.""" return self.seed is not None and self.batch_size is not None def set_context(self, context): - """Sets the context of the pool. + """Set the context of the pool. The pool needs to know the batch_size and the seed. Notes ----- Also sets the name of the pool if not set already. - + Parameters ---------- context : elfi.ComputationContext - Returns - ------- - None """ if self.has_context: raise ValueError('Context is already set') @@ -112,8 +112,8 @@ def set_context(self, context): self.name = "{}_{}".format(self.__class__.__name__.lower(), self.seed) def get_batch(self, batch_index, output_names=None): - """Returns a batch from the stores of the pool. - + """Return a batch from the stores of the pool. + Parameters ---------- batch_index : int @@ -123,8 +123,8 @@ def get_batch(self, batch_index, output_names=None): Returns ------- batch : dict - """ + """ output_names = output_names or self.output_names batch = dict() for output in output_names: @@ -136,7 +136,7 @@ def get_batch(self, batch_index, output_names=None): return batch def add_batch(self, batch, batch_index): - """Adds the outputs from the batch to their stores.""" + """Add the outputs from the batch to their stores.""" for node, values in batch.items(): if node not in self.stores: continue @@ -149,29 +149,27 @@ def add_batch(self, batch, batch_index): store[batch_index] = values def remove_batch(self, batch_index): - """Removes the batch from all the stores.""" + """Remove the batch from all stores.""" for store in self.stores.values(): if batch_index in store: del store[batch_index] def has_store(self, node): + """Check if `node` is in stores.""" return node in self.stores def get_store(self, node): + """Return the store for `node`.""" return self.stores[node] def add_store(self, node, store=None): - """Adds a store object for the node. + """Add a store object for the node. Parameters ---------- node : str store : dict, StoreBase, optional - Returns - ------- - None - """ if node in self.stores and self.stores[node] is not None: raise ValueError("Store for '{}' already exists".format(node)) @@ -180,7 +178,7 @@ def add_store(self, node, store=None): self.stores[node] = store def remove_store(self, node): - """Removes a store from the pool + """Remove and return a store from the pool. Parameters ---------- @@ -190,25 +188,26 @@ def remove_store(self, node): ------- store The removed store + """ store = self.stores.pop(node) return store def _get_store_for(self, node): - """Gets or makes a store.""" + """Get or make a store.""" if self.stores[node] is None: self.stores[node] = self._make_store_for(node) return self.stores[node] def _make_store_for(self, node): - """Make a default store for a node + """Make a default store for a node. All the default stores will be created through this method. """ return {} def __len__(self): - """Largest batch index in any of the stores""" + """Return the largest batch index in any of the stores.""" l = 0 for output, store in self.stores.items(): if store is None: @@ -217,17 +216,19 @@ def __len__(self): return l def __getitem__(self, batch_index): - """Return the batch""" + """Return the batch.""" return self.get_batch(batch_index) def __setitem__(self, batch_index, batch): + """Add `batch` into location `batch_index`.""" return self.add_batch(batch, batch_index) def __contains__(self, batch_index): + """Check if the pool contains `batch_index`.""" return len(self) > batch_index def clear(self): - """Removes all data from the stores""" + """Remove all data from the stores.""" for store in self.stores.values(): store.clear() @@ -251,7 +252,7 @@ def save(self): filename = node + '.pkl' try: pickle.dump(store, open(filename, 'wb')) - except: + except BaseException: raise IOError('Failed to pickle the store for node {}, please check that ' 'it is pickleable or remove it before saving.'.format(node)) os.chdir(cwd) @@ -267,7 +268,8 @@ def save(self): def close(self): """Save and close the stores that support it. - The pool will not be usable afterwards.""" + The pool will not be usable afterwards. + """ self.save() for store in self.stores.values(): @@ -275,9 +277,9 @@ def close(self): store.close() def flush(self): - """Flushes all data from the stores. + """Flush all data from the stores. - If the store does not support flushing, does nothing. + If the store does not support flushing, do nothing. """ for store in self.stores.values(): if hasattr(store, 'flush'): @@ -343,6 +345,7 @@ def _make_path(cls, name, prefix): @property def path(self): + """Return the path to the pool.""" if self.name is None: return None @@ -385,34 +388,40 @@ class StoreBase: Any dictionary like object will work directly as an ELFI store. """ + def __getitem__(self, batch_index): + """Return a batch from location `batch_index`.""" raise NotImplementedError def __setitem__(self, batch_index, data): + """Set array to `data` at location `batch_index`.""" raise NotImplementedError def __delitem__(self, batch_index): + """Delete data from location `batch_index`.""" raise NotImplementedError def __contains__(self, batch_index): + """Check if array contains `batch_index`.""" raise NotImplementedError def __len__(self): - """Number of batches in the store""" + """Return the number of batches in the store.""" raise NotImplementedError def clear(self): - """Remove all batches from the store""" + """Remove all batches from the store.""" raise NotImplementedError def close(self): """Close the store. - Optional method. Useful for closing i.e. file streams""" + Optional method. Useful for closing i.e. file streams. + """ pass def flush(self): - """Flush the store + """Flush the store. Optional to implement. """ @@ -435,9 +444,11 @@ class ArrayStore(StoreBase): batch_size : int n_batches : int How many batches are available from the underlying array. + """ + def __init__(self, array, batch_size, n_batches=-1): - """ + """Initialize ArrayStore. Parameters ---------- @@ -448,8 +459,8 @@ def __init__(self, array, batch_size, n_batches=-1): n_batches : int, optional How many batches should be made available from the array. Default is -1 meaning all available batches. - """ + """ if n_batches == -1: if len(array) % batch_size != 0: logger.warning("The array length is not divisible by the batch size.") @@ -460,10 +471,12 @@ def __init__(self, array, batch_size, n_batches=-1): self.n_batches = n_batches def __getitem__(self, batch_index): + """Return a batch from location `batch_index`.""" sl = self._to_slice(batch_index) return self.array[sl] def __setitem__(self, batch_index, data): + """Set array to `data` at location `batch_index`.""" if batch_index > self.n_batches: raise IndexError("Appending further than to the end of the store array is " "currently not supported.") @@ -478,9 +491,11 @@ def __setitem__(self, batch_index, data): self.n_batches += 1 def __contains__(self, batch_index): + """Check if array contains `batch_index`.""" return batch_index < self.n_batches def __delitem__(self, batch_index): + """Delete data from location `batch_index`.""" if batch_index not in self: raise IndexError("Cannot remove, batch index {} is not in the array" .format(batch_index)) @@ -493,37 +508,43 @@ def __delitem__(self, batch_index): self.n_batches -= 1 def __len__(self): + """Return the number of batches in store.""" return self.n_batches def _to_slice(self, batch_index): - a = self.batch_size*batch_index + """Return a slice object that covers the batch at `batch_index`.""" + a = self.batch_size * batch_index return slice(a, a + self.batch_size) def clear(self): + """Clear array from store.""" if hasattr(self.array, 'clear'): self.array.clear() self.n_batches = 0 def flush(self): + """Flush any changes in memory to array.""" if hasattr(self.array, 'flush'): self.array.flush() def close(self): + """Close array.""" if hasattr(self.array, 'close'): self.array.close() def __del__(self): + """Close array.""" self.close() class NpyStore(ArrayStore): - """Store data to binary .npy files + """Store data to binary .npy files. Uses the NpyArray objects as an array store. """ def __init__(self, file, batch_size, n_batches=-1): - """ + """Initialize NpyStore. Parameters ---------- @@ -533,11 +554,13 @@ def __init__(self, file, batch_size, n_batches=-1): n_batches : int, optional How many batches to make available from the file. Default -1 indicates that all available batches. + """ array = file if isinstance(file, NpyArray) else NpyArray(file) super(NpyStore, self).__init__(array, batch_size, n_batches) def __setitem__(self, batch_index, data): + """Set array to `data` at location `batch_index`.""" sl = self._to_slice(batch_index) # NpyArray supports appending if batch_index == self.n_batches and sl.start == len(self.array): @@ -548,22 +571,29 @@ def __setitem__(self, batch_index, data): super(NpyStore, self).__setitem__(batch_index, data) def __delitem__(self, batch_index): + """Delete data from location `batch_index`.""" super(NpyStore, self).__delitem__(batch_index) sl = self._to_slice(batch_index) self.array.truncate(sl.start) def delete(self): + """Delete array.""" self.array.delete() class NpyArray: - """ + """Extension to NumPy's .npy format. + + The NpyArray is a wrapper over NumPy .npy binary file for array data and supports + appending the .npy file. Notes ----- - Supports only binary files. - Supports only .npy version 2.0 - - See numpy.lib.npformat for documentation of the .npy format """ + - See numpy.lib.npformat for documentation of the .npy format + + """ MAX_SHAPE_LEN = 2**64 @@ -572,7 +602,7 @@ class NpyArray: HEADER_DATA_SIZE_OFFSET = 8 def __init__(self, filename, array=None, truncate=False): - """ + """Initialize NpyArray. Parameters ---------- @@ -582,8 +612,8 @@ def __init__(self, filename, array=None, truncate=False): Initial array truncate : bool Whether to truncate the file or not - """ + """ self.header_length = None self.itemsize = None @@ -616,32 +646,35 @@ def __init__(self, filename, array=None, truncate=False): self.flush() def __getitem__(self, sl): + """Return a slice `sl` of data.""" if not self.initialized: raise IndexError("NpyArray is not initialized") order = 'F' if self.fortran_order else 'C' # TODO: do not recreate if nothing has changed - mmap = np.memmap(self.fs, dtype=self.dtype, shape=self.shape, - offset=self.header_length, order=order) + mmap = np.memmap( + self.fs, dtype=self.dtype, shape=self.shape, offset=self.header_length, order=order) return mmap[sl] def __setitem__(self, sl, value): + """Set data at slice `sl` to `value`.""" if not self.initialized: raise IndexError("NpyArray is not initialized") order = 'F' if self.fortran_order else 'C' - mmap = np.memmap(self.fs, dtype=self.dtype, shape=self.shape, - offset=self.header_length, order=order) + mmap = np.memmap( + self.fs, dtype=self.dtype, shape=self.shape, offset=self.header_length, order=order) mmap[sl] = value def __len__(self): + """Return the length of array.""" return self.shape[0] if self.shape else 0 @property def size(self): - """Number of items in the array""" + """Return the number of items in the array.""" return np.prod(self.shape) def append(self, array): - """Append data from array to self.""" + """Append data from `array` to self.""" if self.closed: raise ValueError('Array is not opened.') @@ -654,16 +687,16 @@ def append(self, array): raise ValueError("Appended array is of different dtype.") # Append new data - pos = self.header_length + self.size*self.itemsize + pos = self.header_length + self.size * self.itemsize self.fs.seek(pos) self.fs.write(array.tobytes('C')) - self.shape = (self.shape[0] + len(array),) + self.shape[1:] + self.shape = (self.shape[0] + len(array), ) + self.shape[1:] # Only prepare the header bytes, need to be flushed to take effect self._prepare_header_data() def _init_from_file_header(self): - """Initialize the object from an existing file""" + """Initialize the object from an existing file.""" self.fs.seek(self.HEADER_DATA_SIZE_OFFSET) try: self.shape, fortran_order, self.dtype = \ @@ -679,7 +712,7 @@ def _init_from_file_header(self): 'translate if first to row major (C-style).') # Determine itemsize - shape = (0,) + self.shape[1:] + shape = (0, ) + self.shape[1:] self.itemsize = np.empty(shape=shape, dtype=self.dtype).itemsize def init_from_array(self, array): @@ -693,18 +726,17 @@ def init_from_array(self, array): Contains the oversized header bytes """ - if self.initialized: raise ValueError("The array has been initialized already!") - self.shape = (0,) + array.shape[1:] + self.shape = (0, ) + array.shape[1:] self.dtype = array.dtype self.itemsize = array.itemsize # Read header data from array and set modify it to be large for the length # 1_0 is the same for 2_0 d = npformat.header_data_from_array_1_0(array) - d['shape'] = (self.MAX_SHAPE_LEN,) + d['shape'][1:] + d['shape'] = (self.MAX_SHAPE_LEN, ) + d['shape'][1:] d['fortran_order'] = False # Write a prefix for a very long array to make it large enough for appending new @@ -723,16 +755,13 @@ def init_from_array(self, array): self._write_header_data() def truncate(self, length=0): - """Truncates the array to the specified length + """Truncate the array to the specified length. Parameters ---------- length : int Length (=`shape[0]`) of the array to truncate to. Default 0. - Returns - ------- - """ if not self.initialized: raise ValueError('The array must be initialized before it can be truncated. ' @@ -742,22 +771,24 @@ def truncate(self, length=0): raise ValueError('The array has been closed.') # Reset length - self.shape = (length,) + self.shape[1:] + self.shape = (length, ) + self.shape[1:] self._prepare_header_data() - self.fs.seek(self.header_length + self.size*self.itemsize) + self.fs.seek(self.header_length + self.size * self.itemsize) self.fs.truncate() def close(self): + """Close the file.""" if self.initialized: self._write_header_data() self.fs.close() def clear(self): + """Truncate the array to 0.""" self.truncate(0) def delete(self): - """Removes the file and invalidates this array""" + """Remove the file and invalidate this array.""" if self.deleted: return name = self.fs.name @@ -767,10 +798,12 @@ def delete(self): self.header_length = None def flush(self): + """Flush any changes in memory to array.""" self._write_header_data() self.fs.flush() def __del__(self): + """Close the array.""" self.close() def _prepare_header_data(self): @@ -787,8 +820,8 @@ def _prepare_header_data(self): # Pad the end of the header fill_len = self.header_length - h_bytes.tell() if fill_len < 0: - raise OverflowError("File {} cannot be appended. The header is too short.". - format(self.filename)) + raise OverflowError( + "File {} cannot be appended. The header is too short.".format(self.filename)) elif fill_len > 0: h_bytes.write(b'\x20' * fill_len) @@ -809,22 +842,27 @@ def _write_header_data(self): @property def deleted(self): + """Check whether file has been deleted.""" return self.fs is None @property def closed(self): + """Check if file has been deleted or closed.""" return self.deleted or self.fs.closed @property def initialized(self): + """Check if file is open.""" return (not self.closed) and (self.header_length is not None) def __getstate__(self): + """Return a dictionary with a key `filename`.""" if not self.fs.closed: self.flush() return {'filename': self.filename} def __setstate__(self, state): + """Initialize with `filename` from dictionary `state`.""" filename = state.pop('filename') basename = os.path.basename(filename) if os.path.exists(filename): @@ -834,5 +872,3 @@ def __setstate__(self, state): else: self.fs = None raise FileNotFoundError('Could not find the file {}'.format(filename)) - - diff --git a/elfi/utils.py b/elfi/utils.py index 7d35200e..30fd4767 100644 --- a/elfi/utils.py +++ b/elfi/utils.py @@ -1,9 +1,10 @@ +"""Common utilities.""" + import uuid -import scipy.stats as ss -import numpy as np import networkx as nx - +import numpy as np +import scipy.stats as ss SCIPY_ALIASES = { 'normal': 'norm', @@ -15,31 +16,44 @@ def scipy_from_str(name): + """Return the scipy.stats distribution corresponding to `name`.""" name = name.lower() name = SCIPY_ALIASES.get(name, name) return getattr(ss, name) def random_seed(): - # Extract the seed from numpy RandomState. Alternative would be to use - # os.urandom(4) casted as int. + """Extract the seed from numpy RandomState. + + Alternative would be to use os.urandom(4) cast as int. + """ return np.random.RandomState().get_state()[1][0] def random_name(length=4, prefix=''): + """Generate a random string. + + Parameters + ---------- + length : int, optional + prefix : str, optional + + """ return prefix + str(uuid.uuid4().hex[0:length]) def observed_name(name): + """Return `_name_observed`.""" return "_{}_observed".format(name) def args_to_tuple(*args): + """Combine args into a tuple.""" return tuple(args) def is_array(output): - # Ducktyping numpy arrays + """Check if `output` behaves as np.array (simple).""" return hasattr(output, 'shape') @@ -47,52 +61,69 @@ def is_array(output): def nbunch_ancestors(G, nbunch): - # Resolve output ancestors + """Resolve output ancestors.""" ancestors = set(nbunch) for node in nbunch: ancestors = ancestors.union(nx.ancestors(G, node)) return ancestors -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. +def get_sub_seed(seed, sub_seed_index, high=2**31, cache=None): + """Return a sub seed. + + The returned sub seed is unique for its index, i.e. no two indexes can + return the same sub_seed. Parameters ---------- - random_state : np.random.RandomState, int + seed : int sub_seed_index : int high : int upper limit for the range of sub seeds (exclusive) + cache : dict or None, optional + If provided, cached state will be used to compute the next sub_seed. Returns ------- - int - from interval [0, high - 1] + int or tuple + The seed will be from the interval [0, high - 1]. If cache is provided, will also return + the updated cache. Notes ----- + Caching the sub seed generation avoids slowing down of recomputing results with stored values + from ``OutputPool``:s. + There is no guarantee how close the random_states initialized with sub_seeds may end - up to each other. Better option is to use PRNG:s that have an advance or jump + up to each other. Better option would be to use PRNG:s that have an advance or jump functions available. """ - - if isinstance(random_state, (int, np.integer)): - random_state = np.random.RandomState(random_state) - - if sub_seed_index >= high: + if isinstance(seed, np.random.RandomState): + raise ValueError('Seed cannot be a random state') + elif sub_seed_index >= high: raise ValueError("Sub seed index {} is out of range".format(sub_seed_index)) - n_unique = 0 - n_unique_required = sub_seed_index + 1 + if cache and len(cache['seen']) < sub_seed_index + 1: + random_state = cache['random_state'] + seen = cache['seen'] + else: + random_state = np.random.RandomState(seed) + seen = set() + sub_seeds = None - seen = set() + n_unique_required = sub_seed_index + 1 + n_unique = len(seen) + while n_unique != n_unique_required: n_draws = n_unique_required - n_unique sub_seeds = random_state.randint(high, size=n_draws, dtype='uint32') seen.update(sub_seeds) n_unique = len(seen) - return sub_seeds[-1] + sub_seed = sub_seeds[-1] + if cache is not None: + cache = {'random_state': random_state, 'seen': seen} + return sub_seed, cache + else: + return sub_seed diff --git a/elfi/visualization/__init__.py b/elfi/visualization/__init__.py index e69de29b..6e031999 100644 --- a/elfi/visualization/__init__.py +++ b/elfi/visualization/__init__.py @@ -0,0 +1 @@ +# noqa: D104 diff --git a/elfi/visualization/interactive.py b/elfi/visualization/interactive.py index 684f2259..2b28df10 100644 --- a/elfi/visualization/interactive.py +++ b/elfi/visualization/interactive.py @@ -1,20 +1,32 @@ +"""This module contains functions for interactive ("iterative") plotting.""" + import logging import matplotlib.pyplot as plt - import numpy as np logger = logging.getLogger(__name__) def plot_sample(samples, nodes=None, n=-1, displays=None, **options): - """ + """Plot a scatterplot of samples. + Experimental, only dims 1-2 supported. + + Parameters + ---------- + samples : Sample + nodes : str or list[str], optional + n : int, optional + Number of plotted samples [0, n). + displays : IPython.display.HTML + """ axes = _prepare_axes(options) nodes = nodes or sorted(samples.keys())[:2] - if isinstance(nodes, str): nodes = [nodes] + if isinstance(nodes, str): + nodes = [nodes] if len(nodes) == 1: axes.set_xlabel(nodes[0]) @@ -34,6 +46,7 @@ def plot_sample(samples, nodes=None, n=-1, displays=None, **options): def get_axes(**options): + """Get an Axes object from `options`, or create one if needed.""" if 'axes' in options: return options['axes'] return plt.gca() @@ -64,8 +77,20 @@ def _prepare_axes(options): def draw_contour(fn, bounds, nodes=None, points=None, title=None, **options): - """ + """Plot a contour of a function. + Experimental, only 2D supported. + + Parameters + ---------- + fn : callable + bounds : list[arraylike] + Bounds for the plot, e.g. [(0, 1), (0,1)]. + nodes : list[str], optional + points : arraylike, optional + Additional points to plot. + title : str, optional + """ ax = get_axes(**options) @@ -83,9 +108,9 @@ def draw_contour(fn, bounds, nodes=None, points=None, title=None, **options): except ValueError: 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]) if options.get('interactive'): - plt.scatter(points[-1,0], points[-1,1], color='r') + plt.scatter(points[-1, 0], points[-1, 1], color='r') plt.xlim(bounds[0]) plt.ylim(bounds[1]) @@ -93,5 +118,3 @@ def draw_contour(fn, bounds, nodes=None, points=None, title=None, **options): if nodes: plt.xlabel(nodes[0]) plt.ylabel(nodes[1]) - - diff --git a/elfi/visualization/visualization.py b/elfi/visualization/visualization.py index 82818946..d3736e5a 100644 --- a/elfi/visualization/visualization.py +++ b/elfi/visualization/visualization.py @@ -1,13 +1,15 @@ -import numpy as np -import matplotlib.pyplot as plt +"""This module includes common functions for visualization.""" + from collections import OrderedDict -from elfi.model.elfi_model import ElfiModel, NodeReference, Constant +import matplotlib.pyplot as plt +import numpy as np + +from elfi.model.elfi_model import Constant, ElfiModel, NodeReference def nx_draw(G, internal=False, param_names=False, filename=None, format=None): - """ - Draw the `ElfiModel`. + """Draw the `ElfiModel`. Parameters ---------- @@ -70,21 +72,24 @@ def nx_draw(G, internal=False, param_names=False, filename=None, format=None): def _create_axes(axes, shape, **kwargs): - """Checks the axes and creates them if necessary. + """Check the axes and create them if necessary. Parameters ---------- - axes : one or an iterable of plt.Axes - shape : tuple of ints (x,) or (x,y) + axes : plt.Axes or arraylike of plt.Axes + shape : tuple of int + (x,) or (x,y) + kwargs Returns ------- axes : np.array of plt.Axes kwargs : dict Input kwargs without items related to creating a figure. + """ fig_kwargs = {} - kwargs['figsize'] = kwargs.get('figsize', (16, 4*shape[0])) + kwargs['figsize'] = kwargs.get('figsize', (16, 4 * shape[0])) for k in ['figsize', 'sharex', 'sharey', 'dpi', 'num']: if k in kwargs.keys(): fig_kwargs[k] = kwargs.pop(k) @@ -109,6 +114,7 @@ def _limit_params(samples, selector=None): Returns ------- selected : OrderedDict of np.arrays + """ if selector is None: return samples @@ -135,10 +141,10 @@ def plot_marginals(samples, selector=None, bins=20, axes=None, **kwargs): Returns ------- axes : np.array of plt.Axes + """ samples = _limit_params(samples, selector) ncols = kwargs.pop('ncols', 5) - nrows = kwargs.pop('nrows', 1) kwargs['sharey'] = kwargs.get('sharey', True) shape = (max(1, len(samples) // ncols), min(len(samples), ncols)) axes, kwargs = _create_axes(axes, shape, **kwargs) @@ -167,6 +173,7 @@ def plot_pairs(samples, selector=None, bins=20, axes=None, **kwargs): Returns ------- axes : np.array of plt.Axes + """ samples = _limit_params(samples, selector) shape = (len(samples), len(samples)) @@ -184,10 +191,12 @@ def plot_pairs(samples, selector=None, bins=20, axes=None, **kwargs): # create a histogram with scaled y-axis hist, bin_edges = np.histogram(samples[k1], bins=bins) bar_width = bin_edges[1] - bin_edges[0] - hist = (hist - hist.min()) * (max_samples - min_samples) / (hist.max() - hist.min()) + hist = (hist - hist.min()) * (max_samples - min_samples) / ( + hist.max() - hist.min()) axes[i1, i2].bar(bin_edges[:-1], hist, bar_width, bottom=min_samples, **kwargs) else: - axes[i1, i2].scatter(samples[k2], samples[k1], s=dot_size, edgecolor=edgecolor, **kwargs) + axes[i1, i2].scatter( + samples[k2], samples[k1], s=dot_size, edgecolor=edgecolor, **kwargs) axes[i1, 0].set_ylabel(k1) axes[-1, i1].set_xlabel(k1) @@ -211,6 +220,7 @@ def plot_traces(result, selector=None, axes=None, **kwargs): Returns ------- axes : np.array of plt.Axes + """ samples_sel = _limit_params(result.samples, selector) shape = (len(samples_sel), result.n_chains) diff --git a/requirements-dev.txt b/requirements-dev.txt index a14228a0..74ef217c 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -6,7 +6,6 @@ pytest-cov>=2.4.0 # Linting flake8>=3.0.4 -pep8-naming>=0.4.1 flake8-docstrings>=1.0.2 isort>=4.2.5 flake8-isort>=2.0.1 diff --git a/scripts/MA2_run.py b/scripts/MA2_run.py index 924b1ef4..3c38c410 100644 --- a/scripts/MA2_run.py +++ b/scripts/MA2_run.py @@ -1,7 +1,6 @@ import elfi from elfi.examples import ma2 - # load the model from elfi.examples model = ma2.get_model() @@ -11,4 +10,3 @@ # show summary of results on stdout result.summary() - diff --git a/setup.cfg b/setup.cfg index c629ce25..233e0b04 100644 --- a/setup.cfg +++ b/setup.cfg @@ -3,17 +3,24 @@ universal = 0 [flake8] exclude = .git, - __pycache__, - docs, - build, - dist -max-line-length = 89 + __pycache__, + docs, + build, + dist, + tests +max-line-length = 99 statistics = True count = True +[yapf] +based_on_style = pep8 +column_limit = 99 + [isort] balanced_wrapping = True multi_line_output = 0 +line_length = 99 +known_third_party=matplotlib [tool:pytest] addopts = --doctest-modules diff --git a/setup.py b/setup.py index 12ca54bc..2ef202e4 100644 --- a/setup.py +++ b/setup.py @@ -1,7 +1,7 @@ import os -from setuptools import setup, find_packages from io import open +from setuptools import find_packages, setup packages = ['elfi'] + ['elfi.' + p for p in find_packages('elfi')] @@ -11,10 +11,7 @@ with open('requirements.txt', 'r') as f: requirements = f.read().splitlines() -optionals = { - 'doc': ['Sphinx'], - 'graphviz': ['graphviz>=0.7.1'] -} +optionals = {'doc': ['Sphinx'], 'graphviz': ['graphviz>=0.7.1']} # read version number __version__ = open('elfi/__init__.py').readlines()[-1].split(' ')[-1].strip().strip("'\"") @@ -28,22 +25,17 @@ author='ELFI authors', author_email='elfi-support@hiit.fi', url='http://elfi.readthedocs.io', - install_requires=requirements, extras_require=optionals, - description='Modular ABC inference framework for python', long_description=(open('docs/description.rst').read()), - license='BSD', - - classifiers=['Programming Language :: Python :: 3.5', - 'Topic :: Scientific/Engineering', - 'Topic :: Scientific/Engineering :: Artificial Intelligence', - 'Topic :: Scientific/Engineering :: Bio-Informatics', - 'Topic :: Scientific/Engineering :: Mathematics', - 'Operating System :: OS Independent', - 'Development Status :: 4 - Beta', - 'Intended Audience :: Science/Research', - 'License :: OSI Approved :: BSD License'], - zip_safe = False) + classifiers=[ + 'Programming Language :: Python :: 3.5', 'Topic :: Scientific/Engineering', + 'Topic :: Scientific/Engineering :: Artificial Intelligence', + 'Topic :: Scientific/Engineering :: Bio-Informatics', + 'Topic :: Scientific/Engineering :: Mathematics', 'Operating System :: OS Independent', + 'Development Status :: 4 - Beta', 'Intended Audience :: Science/Research', + 'License :: OSI Approved :: BSD License' + ], + zip_safe=False) diff --git a/tests/conftest.py b/tests/conftest.py index 5edb104a..2f749a3c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,33 +1,34 @@ import logging -import time import os +import time import numpy as np import pytest import elfi import elfi.clients.ipyparallel as eipp -import elfi.clients.native as native import elfi.clients.multiprocessing as mp -import elfi.examples +import elfi.clients.native as native +import elfi.examples.ma2 elfi.clients.native.set_as_default() # Add command line options def pytest_addoption(parser): - parser.addoption("--client", action="store", default="all", + parser.addoption( + "--client", + action="store", + default="all", help="perform the tests for the specified client (default all)") - parser.addoption("--skipslow", action="store_true", - help="skip slow tests") + parser.addoption("--skipslow", action="store_true", help="skip slow tests") """Functional fixtures""" -@pytest.fixture(scope="session", - params=[native, eipp, mp]) +@pytest.fixture(scope="session", params=[native, eipp, mp]) def client(request): """Provides a fixture for all the different supported clients """ @@ -41,7 +42,7 @@ def client(request): try: client = client_module.Client() - except: + except BaseException: pytest.skip("Client {} not available".format(client_name)) yield client @@ -116,7 +117,7 @@ def sleep_model(request): elfi.Summary(no_op, m['slept'], model=m, name='summary') elfi.Distance('euclidean', m['summary'], model=m, name='d') - m.observed['slept'] = ub_sec/2 + m.observed['slept'] = ub_sec / 2 return m @@ -125,7 +126,6 @@ def sleep_model(request): @pytest.fixture() 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) @@ -180,13 +180,14 @@ def run(distribution, *args, rvs=None, **kwargs): assert pdf_none.ndim == 0 if hasattr(distribution, 'logpdf'): - logpdf_none, logpdf1, logpdf2 = test_non_rvs_attr('logpdf', distribution, rvs, *args, **kwargs) + 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) + glpdf_none, glpdf1, glpdf2 = test_non_rvs_attr('gradient_logpdf', distribution, rvs, + *args, **kwargs) return run - diff --git a/tests/functional/test_compilation.py b/tests/functional/test_compilation.py index 190be2d6..1e1e10b3 100644 --- a/tests/functional/test_compilation.py +++ b/tests/functional/test_compilation.py @@ -1,6 +1,5 @@ -import pytest - import ipyparallel +import pytest import elfi @@ -24,7 +23,8 @@ def test_meta_param(ma2): # TODO: add ipyparallel, maybe use dill or cloudpickle # client.ipp_client[:].use_dill() or .use_cloudpickle() def test_batch_index_value(ma2): - bi = lambda meta : meta['batch_index'] + def bi(meta): + return meta['batch_index'] # Test the correct batch_index value m = elfi.ElfiModel() @@ -45,4 +45,3 @@ def test_reduce_compiler(ma2, client): compiled_net2 = client.compile(ma2.source_net, ['MA2']) assert not compiled_net2.has_node('S1') - diff --git a/tests/functional/test_consistency.py b/tests/functional/test_consistency.py index a32a3a16..1908667a 100644 --- a/tests/functional/test_consistency.py +++ b/tests/functional/test_consistency.py @@ -63,25 +63,39 @@ def test_bo(ma2): upd_int = 1 n_evi = 16 init_evi = 10 - bounds = {'t1':(-2,2), 't2':(-1, 1)} + 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) + 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) + 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) + 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) @@ -98,22 +112,40 @@ def test_bolfi(ma2): upd_int = 1 n_evi = 16 init_evi = 10 - bounds = {'t1':(-2,2), 't2':(-1, 1)} + 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) + 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) + 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) + 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_custom_outputs.py b/tests/functional/test_custom_outputs.py index 58b004d3..d6ba13ed 100644 --- a/tests/functional/test_custom_outputs.py +++ b/tests/functional/test_custom_outputs.py @@ -1,9 +1,9 @@ +import numpy as np import pytest + import elfi from elfi.utils import is_array -import numpy as np - def simulator(p, random_state=None): n = 30 @@ -17,7 +17,7 @@ def simulator(p, random_state=None): def summary(dict_data): n = len(dict_data) data = np.array([dict_data[i] for i in range(n)]) - return data/n + return data / n def lsimulator(p, random_state=None): @@ -32,7 +32,7 @@ def lsimulator(p, random_state=None): def lsummary(list_data): n = len(list_data) data = np.array(list_data[:-1]) - return data/(n-1) + return data / (n - 1) def test_dict_output(): diff --git a/tests/functional/test_inference.py b/tests/functional/test_inference.py index 162d5db6..9aae7385 100644 --- a/tests/functional/test_inference.py +++ b/tests/functional/test_inference.py @@ -5,15 +5,11 @@ import elfi from elfi.examples import ma2 +from elfi.methods.bo.utils import minimize, stochastic_optimization from elfi.model.elfi_model import NodeReference -from elfi.methods.bo.utils import stochastic_optimization, minimize slow = pytest.mark.skipif( - pytest.config.getoption("--skipslow"), - reason="--skipslow argument given" -) - - + pytest.config.getoption("--skipslow"), reason="--skipslow argument given") """ This file tests inference methods point estimates with an informative data from the MA2 process. @@ -60,7 +56,7 @@ def test_rejection_with_quantile(): assert len(np.unique(res.discrepancies)) == N assert res.accept_rate == quantile - assert res.n_sim == int(N/quantile) + assert res.n_sim == int(N / quantile) @pytest.mark.usefixtures('with_all_clients') @@ -97,14 +93,19 @@ def test_smc(): @slow @pytest.mark.usefixtures('with_all_clients', 'skip_travis') def test_BOLFI(): - 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') - bolfi = elfi.BOLFI(log_d, initial_evidence=20, update_interval=10, batch_size=5, - bounds={'t1':(-2,2), 't2':(-1, 1)}, acq_noise_var=.1) + bolfi = elfi.BOLFI( + log_d, + initial_evidence=20, + update_interval=10, + batch_size=5, + bounds={'t1': (-2, 2), + 't2': (-1, 1)}, + acq_noise_var=.1) n = 300 res = bolfi.infer(300) assert bolfi.target_model.n_evidence == 300 @@ -115,25 +116,25 @@ def test_BOLFI(): 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) - assert bolfi.target_model.n_evidence == n+10 - assert np.array_equal(bolfi.target_model._gp.X[:n,:], 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.extract_posterior() # TODO: make cleaner. - 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] + 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 # provide its options. - post_map = stochastic_optimization(post._neg_unnormalized_logposterior, - post.model.bounds)[0] + 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]])) @@ -142,7 +143,8 @@ def test_BOLFI(): 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_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))) @@ -150,16 +152,16 @@ def test_BOLFI(): 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)) + 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[:,:,0], grad_cached_mu)) - assert(np.allclose(grad_var, grad_cached_var)) + 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) + true_logpdf_prior += ma2.CustomPrior2.logpdf(x[0, 1], x[0, 0, ], 1) assert np.isclose(true_logpdf_prior, post.prior.logpdf(x[0, :])) diff --git a/tests/functional/test_post_processing.py b/tests/functional/test_post_processing.py index 35060856..bcce3e95 100644 --- a/tests/functional/test_post_processing.py +++ b/tests/functional/test_post_processing.py @@ -4,9 +4,9 @@ import pytest import elfi +import elfi.methods.post_processing as pp 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): @@ -28,14 +28,14 @@ def test_single_parameter_linear_adjustment(): # Hyperparameters mu0, sigma0 = (10, 100) - y_obs = gauss.Gauss(mu, sigma, n_obs=n_obs, batch_size=1, - random_state=np.random.RandomState(seed)) + 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) + 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() @@ -44,11 +44,8 @@ def test_single_parameter_linear_adjustment(): 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']) + 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 np.allclose(_statistics(adj.outputs['mu']), (4.9772879640569778, 0.02058680115402544)) @@ -64,14 +61,14 @@ def test_nonfinite_values(): # Hyperparameters mu0, sigma0 = (10, 100) - y_obs = gauss.Gauss(mu, sigma, n_obs=n_obs, batch_size=1, - random_state=np.random.RandomState(seed)) + 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) + 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() @@ -80,20 +77,17 @@ def test_nonfinite_values(): 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) + 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']) + adj = elfi.adjust_posterior( + model=m, sample=res, parameter_names=['mu'], summary_names=['S1']) - assert np.allclose(_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(): @@ -108,14 +102,19 @@ def test_multi_parameter_linear_adjustment(): 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) + 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'] diff --git a/tests/functional/test_randomness.py b/tests/functional/test_randomness.py index 38598fd5..64559b3e 100644 --- a/tests/functional/test_randomness.py +++ b/tests/functional/test_randomness.py @@ -1,6 +1,5 @@ -import pytest - import numpy as np +import pytest import scipy.stats as ss import elfi @@ -47,15 +46,22 @@ def test_global_random_state_usage(simple_model): def test_get_sub_seed(): n = 100 - rs = np.random.RandomState() - state = rs.get_state() + seed = np.random.randint(2**31) sub_seeds = [] for i in range(n): - rs.set_state(state) - sub_seeds.append(get_sub_seed(rs, i, n)) + sub_seeds.append(get_sub_seed(seed, i, n)) assert len(np.unique(sub_seeds)) == n + # Test the cached version + cache = {} + sub_seeds_cached = [] + for i in range(n): + sub_seed, cache = get_sub_seed(seed, i, n, cache=cache) + sub_seeds_cached.append(sub_seed) + + assert np.array_equal(sub_seeds, sub_seeds_cached) + # Helpers @@ -67,4 +73,4 @@ def random_state_equal(st1, st2): tf = tf and np.array_equal(st1[1], st2[1]) # 3. an integer ``pos``. tf = tf and st1[2] == st2[2] - return tf \ No newline at end of file + return tf diff --git a/tests/functional/test_serialization.py b/tests/functional/test_serialization.py index ac840cd9..07b6c3a2 100644 --- a/tests/functional/test_serialization.py +++ b/tests/functional/test_serialization.py @@ -2,10 +2,10 @@ import numpy as np -from elfi.model.elfi_model import ComputationContext -from elfi.examples import ma2 from elfi.client import ClientBase +from elfi.examples import ma2 from elfi.executor import Executor +from elfi.model.elfi_model import ComputationContext def test_pickle_ma2(): @@ -40,4 +40,4 @@ def test_pickle_ma2_compiled_and_loaded(ma2): result = Executor.execute(loaded) res2 = result['d'] - assert np.array_equal(res1, res2) \ No newline at end of file + assert np.array_equal(res1, res2) diff --git a/tests/functional/test_simulation_reuse.py b/tests/functional/test_simulation_reuse.py index c7eb9c53..f383d794 100644 --- a/tests/functional/test_simulation_reuse.py +++ b/tests/functional/test_simulation_reuse.py @@ -1,8 +1,8 @@ -import pytest -import time import os +import time import numpy as np +import pytest import elfi @@ -64,12 +64,12 @@ def test_pool_restarts(ma2): pool.get_store('t1').array.fs.flush() pool.get_store('d').array.fs.flush() - assert(len(pool)==6) - assert(len(pool.stores['t1'].array)==60) + assert (len(pool) == 6) + assert (len(pool.stores['t1'].array) == 60) pool2 = elfi.ArrayPool.open('test') - assert(len(pool2)==3) - assert(len(pool2.stores['t1'].array)==30) + assert (len(pool2) == 3) + assert (len(pool2.stores['t1'].array) == 30) rej = elfi.Rejection(ma2, 'd', batch_size=10, pool=pool2) s9pool = rej.sample(3, n_sim=90) @@ -92,7 +92,3 @@ def test_pool_restarts(ma2): pool2.delete() os.rmdir(pool.prefix) - - - - diff --git a/tests/unit/test_bo.py b/tests/unit/test_bo.py index 45287f07..1cf006e6 100644 --- a/tests/unit/test_bo.py +++ b/tests/unit/test_bo.py @@ -1,6 +1,5 @@ -import pytest - import numpy as np +import pytest import elfi import elfi.methods.bo.acquisition as acquisition @@ -15,10 +14,9 @@ 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=bounds) + 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=bounds) assert bo.target_model.n_evidence == n_init assert bo.n_evidence == n_init assert bo.n_precomputed_evidence == n_init @@ -40,15 +38,14 @@ def test_BO(ma2): 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_array[:,0]) + assert np.array_equal(bo.target_model._gp.X[:n_init, 0], res_init.samples_array[:, 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) + 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) @@ -56,10 +53,9 @@ def test_async(ma2): @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) + 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_evidence == 0 @@ -77,7 +73,7 @@ def test_acquisition(): n = 10 n2 = 5 parameter_names = ['a', 'b'] - bounds = {'a':[-2, 3], 'b':[5, 6]} + bounds = {'a': [-2, 3], 'b': [5, 6]} target_model = GPyRegression(parameter_names, bounds=bounds) x1 = np.random.uniform(*bounds['a'], n) x2 = np.random.uniform(*bounds['b'], n) @@ -121,8 +117,7 @@ def test_acquisition(): # test Uniform Acquisition t = 1 - acquisition_method = acquisition.UniformAcquisition(target_model, - noise_var=acq_noise_var) + 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_client.py b/tests/unit/test_client.py index a5ff59dd..fb5cda04 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -1,14 +1,13 @@ -import pytest - import numpy as np +import pytest import scipy.stats as ss import elfi import elfi.client + @pytest.mark.usefixtures('with_all_clients') def test_batch_handler(simple_model): - m = simple_model computation_context = elfi.ComputationContext(seed=123, batch_size=10) batches = elfi.client.BatchHandler(m, computation_context, 'k2') diff --git a/tests/unit/test_document_examples.py b/tests/unit/test_document_examples.py index 9f1b6034..f873bb04 100644 --- a/tests/unit/test_document_examples.py +++ b/tests/unit/test_document_examples.py @@ -52,8 +52,7 @@ def extract_result(self): parameter_names=self.parameter_names, discrepancy_name=self.discrepancy_name, n_sim=self.state['n_sim'], - threshold=self.threshold - ) + threshold=self.threshold) # Below is from the part where we demonstrate iterative advancing diff --git a/tests/unit/test_elfi_model.py b/tests/unit/test_elfi_model.py index 85152ff4..14cab859 100644 --- a/tests/unit/test_elfi_model.py +++ b/tests/unit/test_elfi_model.py @@ -1,3 +1,5 @@ +import os + import numpy as np import pytest @@ -69,22 +71,35 @@ def test_remove_node(self, ma2): assert 'MA2' not in ma2.observed + def test_save_load(self, ma2): + name = ma2.name + ma2.save() + ma2 = elfi.load_model(name) + os.remove(name + '.pkl') + + # Same with a prefix + prefix = 'models_dir' + ma2.save(prefix) + ma2 = elfi.load_model(name, prefix) + os.remove(os.path.join(prefix, name + '.pkl')) + os.removedirs(prefix) + class TestNodeReference: def test_name_argument(self): # This is important because it is used when passing NodeReferences as # InferenceMethod arguments - em.set_current_model() + em.set_default_model() ref = em.NodeReference(name='test') assert str(ref) == 'test' def test_name_determination(self): - em.set_current_model() + em.set_default_model() node = em.NodeReference() assert node.name == 'node' # Works without spaces - node2=em.NodeReference() + node2 = em.NodeReference() assert node2.name == 'node2' # Does not give the same name @@ -100,8 +115,8 @@ def test_name_determination(self): for i in range(5): nodes.append(em.NodeReference()) - for i in range(1,5): - assert nodes[i-1].name != nodes[i].name + for i in range(1, 5): + assert nodes[i - 1].name != nodes[i].name def test_positional_parents(self, ma2): true_positional_parents = ['S1', 'S2'] diff --git a/tests/unit/test_examples.py b/tests/unit/test_examples.py index b742144a..92c6feff 100644 --- a/tests/unit/test_examples.py +++ b/tests/unit/test_examples.py @@ -1,13 +1,14 @@ -import pytest import os +import pytest + import elfi -import elfi.examples as ee +from elfi.examples import bdm, gauss, ricker, gnk, bignk -def test_bdm(recwarn): +def test_bdm(): """Currently only works in unix-like systems and with a cloned repository""" - cpp_path = ee.bdm.get_sources_path() + cpp_path = bdm.get_sources_path() do_cleanup = False if not os.path.isfile(cpp_path + '/bdm'): @@ -21,17 +22,17 @@ def test_bdm(recwarn): os.system('rm bdm') with pytest.warns(RuntimeWarning): - bdm = ee.bdm.get_model() + m = bdm.get_model() # Copy the file here to run the test os.system('cp {}/bdm .'.format(cpp_path)) # Should no longer warn - bdm = ee.bdm.get_model() + m = bdm.get_model() # Test that you can run the inference - rej = elfi.Rejection(bdm, 'd', batch_size=100) + rej = elfi.Rejection(m, 'd', batch_size=100) rej.sample(20) # TODO: test the correctness of the result @@ -42,12 +43,24 @@ def test_bdm(recwarn): def test_Gauss(): - m = ee.gauss.get_model() + m = gauss.get_model() rej = elfi.Rejection(m, m['d'], batch_size=10) rej.sample(20) def test_Ricker(): - m = ee.ricker.get_model() + m = ricker.get_model() + rej = elfi.Rejection(m, m['d'], batch_size=10) + rej.sample(20) + + +def test_gnk(): + m = gnk.get_model() + rej = elfi.Rejection(m, m['d'], batch_size=10) + rej.sample(20) + + +def test_bignk(stats_summary=['ss_octile']): + m = bignk.get_model() rej = elfi.Rejection(m, m['d'], batch_size=10) rej.sample(20) diff --git a/tests/unit/test_mcmc.py b/tests/unit/test_mcmc.py index 014171d9..7c54dd70 100644 --- a/tests/unit/test_mcmc.py +++ b/tests/unit/test_mcmc.py @@ -4,7 +4,7 @@ # construct a covariance matrix and calculate the precision matrix n = 5 -true_cov = np.random.rand(n,n) * 0.5 +true_cov = np.random.rand(n, n) * 0.5 true_cov += true_cov.T true_cov += n * np.eye(n) prec = np.linalg.inv(true_cov) @@ -43,16 +43,16 @@ def test_nuts(self): # some data generated in PyStan -chains_Stan = np.array([[ 0.2955857 , 1.27937191, 1.05884099, 0.91236858], - [ 0.38128885, 1.34242613, 0.49102573, 0.76061715], - [ 0.38128885, 1.18404563, 0.49102573, 0.78910512], - [ 0.38128885, 0.72150199, 0.49102573, 1.13845618], - [ 0.38128885, 0.72150199, 0.38102685, 0.81298041], - [ 0.26917982, 0.72150199, 0.38102685, 0.81298041], - [ 0.26917982, 0.68149163, 0.45830605, 0.86364605], - [ 0.51213898, 0.68149163, 0.29170172, 0.80734373], - [ 0.51213898, 0.85560228, 0.29170172, 0.48134129], - [ 0.22711558, 0.85560228, 0.29170172, 0.48134129]]).T +chains_Stan = np.array([[0.2955857, 1.27937191, 1.05884099, 0.91236858], [ + 0.38128885, 1.34242613, 0.49102573, 0.76061715 +], [0.38128885, 1.18404563, 0.49102573, + 0.78910512], [0.38128885, 0.72150199, 0.49102573, + 1.13845618], [0.38128885, 0.72150199, 0.38102685, + 0.81298041], [0.26917982, 0.72150199, 0.38102685, 0.81298041], + [0.26917982, 0.68149163, 0.45830605, + 0.86364605], [0.51213898, 0.68149163, 0.29170172, 0.80734373], + [0.51213898, 0.85560228, 0.29170172, + 0.48134129], [0.22711558, 0.85560228, 0.29170172, 0.48134129]]).T ess_Stan = 4.09 Rhat_Stan = 1.714 diff --git a/tests/unit/test_methods.py b/tests/unit/test_methods.py index 4f880db1..1ad77ed0 100644 --- a/tests/unit/test_methods.py +++ b/tests/unit/test_methods.py @@ -1,9 +1,8 @@ -import pytest - import numpy as np +import pytest import elfi - +import elfi.examples.ma2 as exma2 from elfi.methods.parameter_inference import ParameterInference @@ -20,7 +19,7 @@ def test_smc(ma2): N = 1000 smc = elfi.SMC(ma2['d'], batch_size=20000) res = smc.sample(N, thresholds=thresholds) - dens = res.populations[0].outputs[smc.prior_logpdf] + dens = smc._prior.logpdf(res.samples_array) # Test that the density is uniform assert np.allclose(dens, dens[0]) @@ -40,35 +39,43 @@ def test_smc(ma2): res.sample_means_summary() res.sample_means_summary(all=True) + # Ensure prior pdf > 0 for samples + assert np.all(exma2.CustomPrior1.pdf(samples[:, 0], 2) > 0) + assert np.all(exma2.CustomPrior2.pdf(samples[:, 1], samples[:, 0], 1) > 0) + # A 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, distribution_test): - # 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={'t1':(-2,2), 't2':(-1, 1)}) + bolfi = elfi.BOLFI( + log_d, + initial_evidence=10, + update_interval=10, + batch_size=5, + bounds={'t1': (-2, 2), + 't2': (-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) + 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.extract_posterior() - distribution_test(post, rvs=(acq_x[0,:], acq_x[1:2,:], acq_x[2:4,:])) + distribution_test(post, rvs=(acq_x[0, :], acq_x[1:2, :], acq_x[2:4, :])) n_samples = 10 n_chains = 2 res_sampling = bolfi.sample(n_samples, n_chains=n_chains) assert res_sampling.samples_array.shape[1] == 2 - assert len(res_sampling.samples_array) == n_samples//2 * n_chains + assert len(res_sampling.samples_array) == n_samples // 2 * n_chains # check the cached predictions for RBF x = np.random.random((1, 2)) @@ -76,10 +83,10 @@ def test_BOLFI_short(ma2, distribution_test): 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)) + 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[:,:,0], grad_cached_mu)) - assert(np.allclose(grad_var, grad_cached_var)) + 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 086f33e1..bff59749 100644 --- a/tests/unit/test_results.py +++ b/tests/unit/test_results.py @@ -7,24 +7,28 @@ def test_sample(): n_samples = 10 parameter_names = ['a', 'b'] distance_name = 'dist' - samples = [np.random.random(n_samples), np.random.random(n_samples), np.random.random(n_samples)] + samples = [ + np.random.random(n_samples), + np.random.random(n_samples), + np.random.random(n_samples) + ] outputs = dict(zip(parameter_names + [distance_name], samples)) - sample = Sample(method_name="TestRes", - outputs=outputs, - parameter_names=parameter_names, - discrepancy_name=distance_name, - something='x', - something_else='y', - n_sim=0, - ) + sample = Sample( + method_name="TestRes", + outputs=outputs, + parameter_names=parameter_names, + discrepancy_name=distance_name, + something='x', + something_else='y', + n_sim=0, ) assert sample.method_name == "TestRes" assert hasattr(sample, 'samples') assert sample.n_samples == n_samples assert sample.dim == len(parameter_names) - assert np.allclose(samples[0], sample.samples_array[:,0]) - assert np.allclose(samples[1], sample.samples_array[:,1]) + assert np.allclose(samples[0], sample.samples_array[:, 0]) + assert np.allclose(samples[1], sample.samples_array[:, 1]) assert np.allclose(samples[-1], sample.discrepancies) assert hasattr(sample, 'something') @@ -44,14 +48,14 @@ def test_bolfi_sample(): parameter_names = ['a', 'b'] chains = np.random.random((n_chains, n_iters, len(parameter_names))) - result = BolfiSample(method_name="TestRes", - chains=chains, - parameter_names=parameter_names, - warmup=warmup, - something='x', - something_else='y', - n_sim=0, - ) + result = BolfiSample( + method_name="TestRes", + chains=chains, + parameter_names=parameter_names, + warmup=warmup, + something='x', + something_else='y', + n_sim=0, ) assert result.method_name == "TestRes" assert hasattr(result, 'samples') diff --git a/tests/unit/test_store.py b/tests/unit/test_store.py index b508cec7..9fcb1425 100644 --- a/tests/unit/test_store.py +++ b/tests/unit/test_store.py @@ -1,11 +1,11 @@ import os import pickle -import pytest import numpy as np +import pytest import elfi -from elfi.store import OutputPool, NpyArray, ArrayPool, ArrayStore, NpyStore +from elfi.store import ArrayPool, ArrayStore, NpyArray, NpyStore, OutputPool def test_npy_array(): @@ -13,7 +13,7 @@ def test_npy_array(): original = np.random.rand(3, 2) append = np.random.rand(10, 2) - ones = np.ones((10,2)) + ones = np.ones((10, 2)) append2 = np.random.rand(23, 2) arr = NpyArray(filename, truncate=True) @@ -39,7 +39,7 @@ def test_npy_array(): # Test that writing over the array fails with pytest.raises(Exception): - arr[len(loaded):len(loaded)+10, :] = ones + arr[len(loaded):len(loaded) + 10, :] = ones # Test rewriting arr[3:13, :] = ones @@ -82,7 +82,7 @@ def test_npy_array_multiple_instances(): arr = NpyArray(filename, array=original) arr.flush() arr.append(append) - assert(len(arr) == 13) + assert (len(arr) == 13) arr.fs.flush() @@ -106,9 +106,9 @@ def test_array_pool(ma2): rej_pool = elfi.Rejection(ma2['d'], batch_size=bs, pool=pool) means = rej_pool.sample(N, n_sim=total).sample_means_array - assert len(pool.stores['MA2']) == total/bs - assert len(pool.stores['S1']) == total/bs - assert len(pool) == total/bs + assert len(pool.stores['MA2']) == total / bs + assert len(pool.stores['S1']) == total / bs + assert len(pool) == total / bs assert not 't1' in pool.stores batch2 = pool[2] @@ -117,7 +117,7 @@ def test_array_pool(ma2): pool2 = OutputPool(['MA2', 'S1']) rej = elfi.Rejection(ma2['d'], batch_size=bs, pool=pool2, seed=pool.seed) rej.sample(N, n_sim=total) - for bi in range(int(total/bs)): + for bi in range(int(total / bs)): assert np.array_equal(pool.stores['S1'][bi], pool2.stores['S1'][bi]) # Test running the inference again @@ -125,23 +125,23 @@ def test_array_pool(ma2): # Test using the same pool with another sampler rej_pool_new = elfi.Rejection(ma2['d'], batch_size=bs, pool=pool) - assert len(pool) == total/bs + assert len(pool) == total / bs assert np.array_equal(means, rej_pool_new.sample(N, n_sim=total).sample_means_array) # Test closing and opening the pool pool.close() pool = ArrayPool.open(pool.name) - assert len(pool) == total/bs + assert len(pool) == total / bs pool.close() # Test opening from a moved location os.rename(pool.path, pool.path + '_move') pool = ArrayPool.open(pool.name + '_move') - assert len(pool) == total/bs + assert len(pool) == total / bs assert np.array_equal(pool[2]['S1'], batch2['S1']) # Test adding a random .npy file - r = np.random.rand(3*bs) + r = np.random.rand(3 * bs) newfile = os.path.join(pool.path, 'test.npy') arr = NpyArray(newfile, r) pool.add_store('test', ArrayStore(arr, bs)) @@ -171,22 +171,22 @@ def run_basic_store_tests(store, content): bs = store.batch_size shape = content.shape[1:] batch = np.random.rand(bs, *shape) - l = len(content)//bs + l = len(content) // bs assert len(store) == l - assert np.array_equal(store[1], content[bs:2*bs]) + assert np.array_equal(store[1], content[bs:2 * bs]) store[1] = batch assert len(store) == l assert np.array_equal(store[1], batch) - del store[l-1] + del store[l - 1] - assert len(store) == l-1 + assert len(store) == l - 1 - store[l-1] = batch + store[l - 1] = batch assert len(store) == l store.clear() @@ -194,7 +194,7 @@ def run_basic_store_tests(store, content): # Return the original condition for i in range(l): - store[i] = content[i*bs:(i+1)*bs] + store[i] = content[i * bs:(i + 1) * bs] assert len(store) == l @@ -202,18 +202,18 @@ def run_basic_store_tests(store, content): def test_array_store(): - arr = np.random.rand(40,2) + arr = np.random.rand(40, 2) store = ArrayStore(arr, batch_size=10, n_batches=3) with pytest.raises(IndexError): - store[4] = np.zeros((10,2)) + store[4] = np.zeros((10, 2)) run_basic_store_tests(store, arr[:30]) def test_npy_store(): filename = 'test' - arr = np.random.rand(40,2) + arr = np.random.rand(40, 2) NpyArray(filename, arr).close() store = NpyStore(filename, batch_size=10, n_batches=4) @@ -221,11 +221,11 @@ def test_npy_store(): batch = np.random.rand(10, 2) store[4] = batch - store[5] = 2*batch + store[5] = 2 * batch - assert np.array_equal(store[5], 2*batch) + assert np.array_equal(store[5], 2 * batch) with pytest.raises(IndexError): - store[7] = 3*batch + store[7] = 3 * batch store.delete() diff --git a/tests/unit/test_tools.py b/tests/unit/test_tools.py index 6500c7a7..e386867a 100644 --- a/tests/unit/test_tools.py +++ b/tests/unit/test_tools.py @@ -8,17 +8,17 @@ def test_vectorize_decorator(): batch_size = 3 - a = np.array([1,2,3]) - b = np.array([3,2,1]) + a = np.array([1, 2, 3]) + b = np.array([3, 2, 1]) def simulator(a, b, random_state=None): - return a*b + return a * b vsim = elfi.tools.vectorize(simulator) assert np.array_equal(a * b, vsim(a, b, batch_size=batch_size)) def simulator(a, constant, random_state=None): - return a*constant + return a * constant vsim = elfi.tools.vectorize(simulator, constants=[1]) assert np.array_equal(a * 5, vsim(a, 5, batch_size=batch_size)) @@ -26,16 +26,15 @@ def simulator(a, constant, random_state=None): vsim = elfi.tools.vectorize(simulator, [1]) assert np.array_equal(a * 5, vsim(a, 5, batch_size=batch_size)) - def simulator(constant0, b, constant2, random_state=None): - return constant0*b*constant2 + return constant0 * b * constant2 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): - vsim(2, b, 7, batch_size=2*batch_size) + vsim(2, b, 7, batch_size=2 * batch_size) def simulator(): @@ -63,8 +62,8 @@ def test_external_operation(): @pytest.mark.usefixtures('with_all_clients') def test_vectorized_and_external_combined(): constant = elfi.Constant(123) - kwargs_sim = elfi.tools.external_operation('echo {seed} {batch_index} {index_in_batch} {submission_index}', - process_result='int32') + kwargs_sim = elfi.tools.external_operation( + 'echo {seed} {batch_index} {index_in_batch} {submission_index}', process_result='int32') kwargs_sim = elfi.tools.vectorize(kwargs_sim) sim = elfi.Simulator(kwargs_sim, constant) @@ -84,6 +83,3 @@ def test_vectorized_and_external_combined(): # Test submission_index (all belong to the same submission) assert len(np.unique(g[:, 3]) == 1) - - - diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py index 989063a1..c9c85153 100644 --- a/tests/unit/test_utils.py +++ b/tests/unit/test_utils.py @@ -2,24 +2,29 @@ import scipy.stats as ss import elfi -from elfi.methods.utils import weighted_var, GMDistribution, normalize_weights, \ - ModelPrior, numgrad -from elfi.methods.bo.utils import stochastic_optimization, minimize +from elfi.methods.bo.utils import minimize, stochastic_optimization +from elfi.methods.utils import GMDistribution, ModelPrior, normalize_weights, numgrad, weighted_var def test_stochastic_optimization(): - fun = lambda x : x**2 - bounds = ((-1, 1),) + def fun(x): + return x**2 + + bounds = ((-1, 1), ) its = int(1e3) - polish=True + polish = True loc, val = stochastic_optimization(fun, bounds, its, polish) assert abs(loc - 0.0) < 1e-5 assert abs(val - 0.0) < 1e-5 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]) + def fun(x): + return x[0]**2 + (x[1] - 1)**4 + + def grad(x): + return np.array([2 * x[0], 4 * (x[1] - 1)**3]) + bounds = ((-2, 2), (-2, 3)) loc, val = minimize(fun, bounds, grad) assert np.isclose(val, 0, atol=0.01) @@ -27,7 +32,9 @@ def test_minimize_with_known_gradient(): def test_minimize_with_approx_gradient(): - fun = lambda x : x[0]**2 + (x[1]-1)**4 + def fun(x): + return 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) @@ -43,20 +50,20 @@ def test_weighted_var(): # 2d case cov = [[.5, 0], [0, 3.2]] - x = np.random.RandomState(12345).multivariate_normal([1,2], cov, size=1000) + x = np.random.RandomState(12345).multivariate_normal([1, 2], cov, size=1000) w = np.array([1] * len(x)) assert np.linalg.norm(weighted_var(x, w) - np.diag(cov)) < .1 class TestGMDistribution: - def test_pdf(self, distribution_test): # 1d case x = [1, 2, -1] means = [0, 2] weights = normalize_weights([.4, .1]) d = GMDistribution.pdf(x, means, weights=weights) - d_true = weights[0]*ss.norm.pdf(x, loc=means[0]) + weights[1]*ss.norm.pdf(x, loc=means[1]) + 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 @@ -66,11 +73,11 @@ def test_pdf(self, distribution_test): distribution_test(GMDistribution, means, weights=weights) # 2d case - x = [[1, 2, -1], [0,0,2]] - means = [[0,0,0], [-1,-.2, .1]] + x = [[1, 2, -1], [0, 0, 2]] + means = [[0, 0, 0], [-1, -.2, .1]] d = GMDistribution.pdf(x, means, weights=weights) - d_true = weights[0]*ss.multivariate_normal.pdf(x, mean=means[0]) + \ - weights[1]*ss.multivariate_normal.pdf(x, mean=means[1]) + d_true = weights[0] * ss.multivariate_normal.pdf(x, mean=means[0]) + \ + weights[1] * ss.multivariate_normal.pdf(x, mean=means[1]) assert np.allclose(d, d_true) # Test with a single observation @@ -85,23 +92,32 @@ def test_rvs(self): N = 10000 random = np.random.RandomState(12042017) rvs = GMDistribution.rvs(means, weights=weights, size=N, random_state=random) - rvs = rvs[rvs[:,0] < 0, :] + rvs = rvs[rvs[:, 0] < 0, :] # Test correct proportion of samples near the second mode - assert np.abs(len(rvs)/N - .7) < .01 + assert np.abs(len(rvs) / N - .7) < .01 # Test that the mean of the second mode is correct - assert np.abs(np.mean(rvs[:,1]) + 3) < .1 + assert np.abs(np.mean(rvs[:, 1]) + 3) < .1 + + def test_rvs_prior_ok(self): + means = [0.8, 0.5] + weights = [.3, .7] + N = 10000 + prior_logpdf = ss.uniform(0, 1).logpdf + rvs = GMDistribution.rvs(means, weights=weights, size=N, prior_logpdf=prior_logpdf) + + # Ensure prior pdf > 0 for all samples + assert np.all(np.isfinite(prior_logpdf(rvs))) def test_numgrad(): - assert np.allclose(numgrad(lambda x: np.log(x), 3), [1/3]) + 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()) @@ -129,8 +145,7 @@ def test_numerical_grad_logpdf(self): loc = 2.2 scale = 1.1 x = np.random.rand() - analytical_grad_logpdf = -(x - loc) / scale ** 2 + 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) -