diff --git a/.circleci/config.yml b/.circleci/config.yml index 1fb9d00c..8296942d 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -6,20 +6,22 @@ jobs: build: docker: - - image: circleci/python:3.7.0 + - image: cimg/base:2024.12 + #- image: cimg/base:2021.04 + #- image: circleci/python:3.13.1 steps: - checkout - run: sudo apt-get update -y - - run: sudo apt-get install -y python3-dev python3-mpi4py python3-h5py python3-numpy python3-scipy python3-matplotlib python3-pandas openmpi-common libopenmpi-dev libhdf5-dev - - run: sudo ln -s /usr/lib/python3/dist-packages/numpy/core/include/numpy/ /usr/include/numpy + - run: sudo apt-get install -y python3-pip openmpi-common libopenmpi-dev liblapack-dev libopenblas-dev libhdf5-dev + + - run: python3 -m pip install --user -r pip-requirements.txt pytest-html coveralls pyyaml mpi4py pydocstyle pycodestyle flake8 - - run: sudo pip3 install -r pip-requirements.txt pytest-html coveralls pyyaml mpi4py - run: mkdir -p test-reports - - run: python3 setup.py install --user + - run: python3 -m pip install --user . - run: for i in examples/test*.py; do python3 $i --help; done - run: coverage3 run --parallel-mode setup.py test @@ -27,11 +29,19 @@ jobs: echo "backend: Agg" > matplotlibrc - run: python3 examples/testfeatures.py - - run: python3 examples/rundirichlet.py + - run: + no_output_timeout: 20m + command: python3 examples/rundirichlet.py + - run: coverage3 run --parallel-mode docs/simple.py + - run: coverage3 run --parallel-mode docs/gauss.py --x_dim=1 --log_dir=tmp - run: coverage3 combine - run: coverage3 report --include="$PWD/*" --omit="$PWD/.eggs/*" - run: coverage3 html --include="$PWD/*" --omit="$PWD/.eggs/*" && mv htmlcov test-reports + - run: flake8 $(ls ultranest/*.py | grep -Ev '^ultranest/(flatnuts|dychmc|dyhmc).py') + - run: pycodestyle $(ls ultranest/*.py | grep -Ev '^ultranest/(flatnuts|dychmc|dyhmc).py') + - run: pydocstyle $(ls ultranest/*.py | grep -Ev '^ultranest/(flatnuts|dychmc|dyhmc).py') + - run: coveralls - store_test_results: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 00000000..df4aa89d --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,44 @@ +# This workflow will install Python dependencies, run tests and lint with a variety of Python versions +# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions + +name: build + +on: + push: + pull_request: + schedule: + - cron: '42 4 5,20 * *' + +jobs: + build: + + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: [3.8, 3.9, "3.10", 3.11, 3.12] + + steps: + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: python -m pip install cython numpy scipy matplotlib corner getdist h5py pandas flake8 pycodestyle pydocstyle pytest-html pytest-xdist + + - name: Lint with flake8 + run: flake8 $(ls ultranest/*.py | grep -Ev '^ultranest/(flatnuts|dychmc|dyhmc|pathsampler).py') + + - name: Check code style + run: pycodestyle $(ls ultranest/*.py | grep -Ev '^ultranest/(flatnuts|dychmc|dyhmc|pathsampler).py') + + - name: Check doc style + run: pydocstyle $(ls ultranest/*.py | grep -Ev '^ultranest/(flatnuts|dychmc|dyhmc|pathsampler).py') + + - name: Install package + run: python -m pip install -e . + + - name: Test with pytest + run: pytest -v -k 'not SLOW' diff --git a/HISTORY.rst b/HISTORY.rst index d1c807f0..c3e9fdee 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -2,6 +2,81 @@ Release Notes ============== +4.4.0 (2024-12-13) +------------------ +* Compatible with numpy version 2 and above. Any remaining errors like "ValueError: numpy.dtype size changed, may indicate binary incompatibility. Expected 96 from C header, got 88 from PyObject" are due to ultranest and numpy being installed with different versions. Reinstall numpy and ultranest in that case. + +4.3.0 (2024-04-12) +------------------ +* added :py:class:`ultranest.popstepsampler.PopulationSimpleSliceSampler`: Vectorized, fixed-batch size slice sampler. (`PR `_ by Benjamin Beauchesne) +* validation of passed parameter names (`PR `_ by svaverbe) +* documentation improvements, including documenting results dictionary (`PR `_ by Jacopo Tissino) +* make scipy actually optional (`PR `_ by Matthew Kirk) +* linting + +4.2.0 (2024-02-15) +------------------ + +* new :py:class:`ultranest.mlfriends.LocalAffineLayer` for metric learning, set as default (see `issue 124 `_) +* add Highest Density Interval function (ultranest.plot.highest_density_interval_from_samples) +* corner plot style with higher signal-to-ink ratio. +* bug fixes in popstepsampler + +4.1.0 (2024-02-15) +------------------ + +* add number of steps calibrator :py:class:`ultranest.calibrator.ReactiveNestedCalibrator` +* add relative jump distance diagnostic for step samplers +* make population step samplers more consistent with other step samplers + +4.0.0 (2024-02-15) +------------------ + +* new :py:class:`ultranest.mlfriends.MaxPrincipleGapAffineLayer` for metric learning, set as default + +3.6.5 (2023-07-18) +------------------ + +* documentation improvements +* logging with MPI fixes `by adipol-ph `_ and `by gregorydavidmartinez `_ +* more flexible plotting `by facero `_ + +3.6.0 (2023-06-22) +------------------ + +* add PopulationRandomWalkSampler: vectorized Gaussian random walks for GPU/JAX-powered likelihoods +* limit initial widening to escape plateau (issue #81) + + +3.5.0 (2022-09-05) +------------------ + +* add hot-resume: resume from a similar fit (with different data) +* fix post_summary.csv column order +* fix build handling for non-pip systems (pyproject.toml) +* more efficient handling of categorical variables + + +3.4.0 (2022-04-05) +------------------ + +* add differential evolution proposal for slice sampling, recommend it +* fix revert of step sampler when run out of constraint, in MPI +* add SimpleRegion: axis-aligned ellipsoidal for very high-d. + + +3.3.3 (2021-09-17) +------------------ + +* pretty marginal posterior plot to stdout +* avoid non-terminations when logzerr cannot be reached +* add RobustEllipsoidRegion: ellipsoidal without MLFriends for high-d. +* add WrappingEllipsoid: for additional rejection. +* bug fixes on rank order test +* add resume-similar +* modular step samplers + + 3.0.0 (2020-10-03) ------------------ diff --git a/MANIFEST.in b/MANIFEST.in index cd5f1998..400e3a90 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -5,8 +5,21 @@ include README.rst include requirements_dev.txt include pip-requirements.txt +recursive-include * *.pyx +recursive-include * *.pxd + recursive-include tests * recursive-exclude * __pycache__ recursive-exclude * *.py[co] +recursive-exclude * *.c +recursive-exclude * *.orig +recursive-exclude * *.pdf recursive-include docs *.rst conf.py Makefile make.bat *.jpg *.png *.gif + +# remove extraneous doc and test outputs +prune docs/static/mcmc-demo +prune tests/reports +prune tests/.pytype +recursive-exclude tests conetestdata.npz +recursive-exclude tests region-stuck*.npz diff --git a/Makefile b/Makefile index f69de529..fc693b21 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: clean clean-test clean-pyc clean-build docs help +.PHONY: clean clean-test clean-pyc clean-build docs servedocs help install release release-test dist .DEFAULT_GOAL := help define BROWSER_PYSCRIPT @@ -43,6 +43,7 @@ clean-build: ## remove build artifacts clean-pyc: ## remove Python file artifacts find . -name '*.pyc' -exec rm -f {} + find . -name '*.pyo' -exec rm -f {} + + find . -name '*.pyx.py' -exec rm -f {} + find . -name '*~' -exec rm -f {} + find . -name '__pycache__' -exec rm -fr {} + find . -name '*.so' -exec rm -f {} + @@ -58,8 +59,12 @@ clean-doc: rm -rf docs/build nbstripout docs/*.ipynb -lint: ## check style with flake8 - flake8 ultranest tests +SOURCES := $(shell ls ultranest/*.py | grep -Ev '^ultranest/(flatnuts|dychmc|dyhmc|pathsampler).py' | grep -v .pyx.py) + +lint: ${SOURCES} ## check style + flake8 ${SOURCES} + pycodestyle ${SOURCES} + pydocstyle ${SOURCES} test: ## run tests quickly with the default Python PYTHONPATH=. pytest @@ -67,6 +72,9 @@ test: ## run tests quickly with the default Python test-all: ## run tests on every Python version with tox tox +build: + $(PYTHON) setup.py build_ext --inplace + coverage: ## check code coverage quickly with the default Python PYTHONPATH=. coverage run --source ultranest -m pytest coverage report -m @@ -76,22 +84,29 @@ coverage: ## check code coverage quickly with the default Python docs: ## generate Sphinx HTML documentation, including API docs rm -f docs/ultranest.rst rm -f docs/modules.rst + rm -f docs/API.rst python3 setup.py build_ext --inplace #nbstripout docs/*.ipynb sphinx-apidoc -H API -o docs/ ultranest + cd docs; python3 modoverview.py $(MAKE) -C docs clean $(MAKE) -C docs html O=-jauto sed --in-place '/href="ultranest\/mlfriends.html"/d' docs/build/html/_modules/index.html + sed --in-place '/href="ultranest\/stepfuncs.html"/d' docs/build/html/_modules/index.html $(BROWSER) docs/build/html/index.html servedocs: docs ## compile the docs watching for changes watchmedo shell-command -p '*.rst' -c '$(MAKE) -C docs html' -R -D . -release: dist ## package and upload a release +release-test: install rm -rf logs/features-* - echo testfeatures/runsettings-*-iterated.json | xargs --max-args=1 mpiexec -np 3 coverage run --parallel-mode examples/testfeatures.py - bash -c 'echo $$RANDOM' | xargs mpiexec -np 5 coverage run --parallel-mode examples/testfeatures.py --random --seed - twine upload -s dist/*.tar.gz + grep -v iterated examples/runfeatures.sh | sed 's,python3,mpiexec -np 5 coverage3 run --parallel-mode,g' | OMP_NUM_THREADS=4 bash + grep iterated examples/runfeatures.sh | sed 's,python3,mpiexec -np 3 coverage3 run --parallel-mode,g' | OMP_NUM_THREADS=4 bash + #echo testfeatures/runsettings-*-iterated.json | xargs --max-args=1 mpiexec -np 3 coverage run --parallel-mode examples/testfeatures.py + #grep -- --random examples/runfeatures.sh | sed s,python3,,g | xargs -rt --max-lines=1 mpiexec -np 5 coverage run --parallel-mode + +release: release-test dist ## package and upload a release + twine upload --verbose dist/*.tar.gz dist: clean ## builds source and wheel package $(PYTHON) setup.py sdist @@ -99,4 +114,4 @@ dist: clean ## builds source and wheel package ls -l dist install: clean ## install the package to the active Python's site-packages - $(PYTHON) setup.py install + $(PYTHON) setup.py install --user diff --git a/README.rst b/README.rst index dbab5f9d..3b928ebd 100644 --- a/README.rst +++ b/README.rst @@ -71,7 +71,7 @@ Features * Can control the run programmatically and check status * Reasonable defaults, but customizable * Thoroughly tested with many unit and integration tests - * NEW: allows likelihood functions written in `Python `_, `C `_, `C++ `_, `Fortran `_, `Julia `_ and `R `_ + * NEW: supports likelihood functions written in `Python `_, `C `_, `C++ `_, `Fortran `_, `Julia `_ and `R `_ * Robust exploration easily handles: @@ -79,25 +79,17 @@ Features * Multiple modes/solutions in the parameter space * Robust, parameter-free MLFriends algorithm (metric learning RadFriends, Buchner+14,+19), with new improvements - (region follows new live points, clustering improves metric iteratively). + (region follows new live points, clustering improves metric iteratively, + NEW in v4.0: refined local metric). * High-dimensional problems with hit-and-run sampling * Wrapped/circular parameters, derived parameters * Fast-slow parameters -* strategic nested sampling - - * can vary (increase) number of live points (akin to dynamic nested sampling, but with different targets) - * can sample clusters optimally (e.g., at least 50 points per cluster/mode/solution) - * can target minimizing parameter estimation uncertainties - * can target a desired evidence uncertainty threshold - * can target a desired number of effective samples - * or any combination of the above - * Robust ln(Z) uncertainties by bootstrapping live points. - * Lightweight and fast * some functions implemented in Cython - * `vectorized likelihood function calls `__ + * `vectorized likelihood function calls `__, + optimally supporting models with deep learning emulators * Use multiple cores, fully parallelizable from laptops to computing clusters * `MPI support `__ @@ -107,20 +99,28 @@ Features * Publication-ready visualisations * Corner plots, run and parameter exploration diagnostic plots * Checkpointing and resuming, even with different number of live points - * NEW: `Warm-start: resume from modified data / model `__ + * `Warm-start: resume from modified data / model `__ -Usage -^^^^^ +* strategic nested sampling -`Get started! `_ + * can vary (increase) number of live points (akin to dynamic nested sampling, but with different targets) + * can sample clusters optimally (e.g., at least 50 points per cluster/mode/solution) + * can target minimizing parameter estimation uncertainties + * can target a desired evidence uncertainty threshold + * can target a desired number of effective samples + * or any combination of the above + * Robust ln(Z) uncertainties by bootstrapping live points. -Read the full documentation with tutorials at: +Usage +^^^^^ -https://johannesbuchner.github.io/UltraNest/ +* `Get started! `_ -`API Reference: `_. +* Read the full documentation with tutorials at: -`Code repository: https://github.com/JohannesBuchner/UltraNest/ `_ + * https://johannesbuchner.github.io/UltraNest/ + * `API Reference `_. + * `Code repository: https://github.com/JohannesBuchner/UltraNest/ `_ Licence ^^^^^^^ @@ -130,3 +130,25 @@ How to `cite UltraNest `_. +It symbolises UltraNest's approach of carefully walking up a likelihood, +ready to defend against any encountered danger. + +Contributors +^^^^^^^^^^^^ + +* Nicholas Susemiehl +* Quinn Gao +* Sigfried Vanaverbeke +* Warrick Ball +* Adipol Phosrisom +* Pieter Vuylsteke +* Alexander Harvey Nitz +* Gregory David Martinez +* Grigorii Smirnov-Pinchukov +* Fabio F Acero +* Jacopo Tissino +* Benjamin Beauchesne +* Kyle Barbary (some ellipsoid code adopted from https://github.com/kbarbary/nestle) +* Adam Moss (some architecture and parallelisation adopted from https://github.com/adammoss/nnest) +* Josh Speagle (some visualisations adopted from https://github.com/joshspeagle/dynesty/) +* Johannes Buchner diff --git a/docs/API.rst b/docs/API.rst new file mode 100644 index 00000000..f85b1788 --- /dev/null +++ b/docs/API.rst @@ -0,0 +1,49 @@ +API +=== + +`Full API documentation on one page `_ + +The main interface is :py:class:`ultranest.integrator.ReactiveNestedSampler`, +also available as `ultranest.ReactiveNestedSampler`. + + +Modules commonly used directly: +-------------------------------------------------------------------------------- + + * :py:mod:`ultranest.integrator`: Nested sampling integrators + * :py:mod:`ultranest.plot`: Plotting utilities + * :py:mod:`ultranest.stepsampler`: MCMC-like step sampling + * :py:mod:`ultranest.popstepsampler`: Vectorized step samplers + * :py:mod:`ultranest.calibrator`: Calibration of step sampler + * :py:mod:`ultranest.solvecompat`: Drop-in replacement for pymultinest.solve. + * :py:mod:`ultranest.hotstart`: Warm start + +Internally used modules: +-------------------------------------------------------------------------------- + + * :py:mod:`ultranest.mlfriends`: Region construction methods + * :py:mod:`ultranest.netiter`: Graph-based nested sampling + * :py:mod:`ultranest.ordertest`: U test for a uniform distribution of integers + * :py:mod:`ultranest.stepfuncs`: Efficient helper functions for vectorized step-samplers + * :py:mod:`ultranest.store`: Storage for nested sampling points + * :py:mod:`ultranest.viz`: Live point visualisations + +Experimental modules, no guarantees: +-------------------------------------------------------------------------------- + + * :py:mod:`ultranest.dychmc`: Constrained Hamiltanean Monte Carlo step sampling. + * :py:mod:`ultranest.dyhmc`: Experimental constrained Hamiltanean Monte Carlo step sampling + * :py:mod:`ultranest.flatnuts`: FLATNUTS is a implementation of No-U-turn sampler + * :py:mod:`ultranest.pathsampler`: MCMC-like step sampling on a trajectory. + * :py:mod:`ultranest.samplingpath`: Sparsely sampled, virtual sampling path. + + +Alphabetical list of submodules +------------------------------- + +.. toctree:: + :maxdepth: 2 + + ultranest + + diff --git a/docs/conf.py b/docs/conf.py index d6586bfc..e6250347 100755 --- a/docs/conf.py +++ b/docs/conf.py @@ -39,6 +39,7 @@ 'sphinx.ext.autodoc', 'sphinx.ext.viewcode', 'sphinx.ext.mathjax', + 'sphinx.ext.doctest', 'sphinx.ext.autosectionlabel', 'nbsphinx', 'sphinx_rtd_theme', @@ -60,7 +61,7 @@ # General information about the project. project = u'UltraNest' -copyright = u"2014-2020, Johannes Buchner" +copyright = u"2014-2024, Johannes Buchner" author = u"Johannes Buchner" # The version info for the project you're documenting, acts as replacement @@ -77,7 +78,7 @@ # # This is also used if you do content translation via gettext catalogs. # Usually you set "language" from the command line for these cases. -language = None +language = 'en' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. @@ -94,11 +95,11 @@ autosectionlabel_prefix_document = True # avoid time-out when running the doc -nbsphinx_timeout = 45 * 60 +nbsphinx_timeout = 4 * 60 * 60 nbsphinx_execute_arguments = [ "--InlineBackend.figure_formats={'svg', 'pdf'}", - "--InlineBackend.rc={'figure.dpi': 96}", + "--InlineBackend.rc=figure.dpi=96", ] autodoc_member_order = 'bysource' @@ -118,12 +119,13 @@ # html_theme = "sphinx_rtd_theme" +html_baseurl = 'https://johannesbuchner.github.io/UltraNest/' + # Theme options are theme-specific and customize the look and feel of a # theme further. For a list of options available for each theme, see the # documentation. # html_theme_options = { - 'canonical_url': 'https://johannesbuchner.github.io/UltraNest/', 'style_external_links': True, # 'vcs_pageview_mode': 'edit', 'style_nav_header_background': '#2980B9', diff --git a/docs/debugging.ipynb b/docs/debugging.ipynb new file mode 100644 index 00000000..f3cb3880 --- /dev/null +++ b/docs/debugging.ipynb @@ -0,0 +1,625 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Debugging techniques\n", + "\n", + "In this tutorial you will learn:\n", + "\n", + " - How to find issues in your model\n", + " - How to debug a interrupted run\n", + " - How to determine causes of slow-down\n", + " - How to debug MPI parallelisation\n", + " - How to check step sampler correctness\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "This tutorial allows you to make sure your code is good, independent of ultranest." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Lets start with the sine example from the [\"Higher-dimensional fitting\" tutorial](https://johannesbuchner.github.io/UltraNest/example-sine-highd.html):" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "import scipy.stats\n", + "import matplotlib.pyplot as plt\n", + "import ultranest\n", + "import corner\n", + "\n", + "from numpy import sin, pi\n", + "\n", + "def sine_model1(t, B, A1, P1, t1):\n", + " return A1 * sin((t / P1 + t1) * 2 * pi) + B\n", + "\n", + "np.random.seed(42)\n", + "\n", + "n_data = 50\n", + "\n", + "# time of observations\n", + "t = np.random.uniform(0, 5, size=n_data)\n", + "# measurement values\n", + "yerr = 1.0\n", + "y = np.random.normal(sine_model1(t, B=1.0, A1=4.2, P1=3, t1=0), yerr)\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Probabilistic model implementation:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "parameters = ['B', 'A1', 'P1', 't1']\n", + "ndim = len(parameters)\n", + "\n", + "def prior_transform(cube):\n", + " params = cube.copy()\n", + " params[0] = cube[0] * 20 - 10\n", + " params[1] = 10**(cube[1] * 3 - 1)\n", + " params[2] = 10**(cube[1] * 2)\n", + " params[3] = cube[3]\n", + " return params\n", + "\n", + "def log_likelihood(params):\n", + " B, A1, P1, t1 = params\n", + " y_model = sine_model1(t, B=B, A1=A1, P1=P1, t1=t1).tolist()\n", + " return scipy.stats.norm(y_model, yerr).logpdf(y).sum()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Finding model bugs" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We have made a happy little mistake in the implementation above." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Finding prior transform bugs" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "To find it, lets sample from the prior:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "p = [prior_transform(np.random.uniform(size=ndim)) for i in range(1000)]" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "corner.corner(np.array(p), titles=parameters, show_titles=True, plot_density=False, quiet=True);" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "See the issue? A1 and P1 are perfectly correlated!" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Here is the bug pointed out, and the corrected version:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "def prior_transform(cube):\n", + " params = cube.copy()\n", + " params[0] = cube[0] * 20 - 10\n", + " params[1] = 10**(cube[1] * 3 - 1)\n", + " params[2] = 10**(cube[1] * 2)\n", + " # ^ ^ \n", + " # |\n", + " # Mistake\n", + " # correct version:\n", + " params[2] = 10**(cube[2] * 2)\n", + " params[3] = cube[3]\n", + " return params" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Finding likelihood function bugs" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Draw uniformly from the prior and plot the model. " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "params = prior_transform(np.random.uniform(size=ndim))\n", + "plt.plot(t, sine_model1(t, *params), 'x ');" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Repeat this a few times and you have prior predictive checks!" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Also have a look at a few randomly drawn likelihood values. If you see values repeated, or infinites, it is not a good sign." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "[log_likelihood(pi) for pi in p[:10]]" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## How can I make the inference go faster?" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "There are two categories of slowdowns:\n", + "\n", + "1. Computational slow-downs: Your model is implemented so it is is slow to evaluate.\n", + "2. Algorithmic slow-downs: Your model is difficult and requires many model evaluations.\n", + "\n", + "Lets find out which one is blocking you most:\n", + "\n", + "### Measuring implementation speed\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Lets measure the speed of our prior transform and model:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "u = np.random.uniform(size=ndim)\n", + "%timeit prior_transform(u)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "p = prior_transform(u)\n", + "%timeit log_likelihood(p)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Here we see that the prior transform is very quick: one evaluation per microsecond. But the likelihood is much slower, with one evaluation per ms. That means for a million samples, we already have to wait 15 minutes." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We should speed it up (see [\"Higher-dimensional fitting\" tutorial](https://johannesbuchner.github.io/UltraNest/example-sine-highd.html) for a faster implementation)." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Measuring algorithmic speed" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "In the ultranest output, look at the sampling efficiency.\n", + "\n", + "If it is a few percent to 100%, the inference is very fast algorithmically, and you should focus on the model computation speed (see above). Switching to a step sampler will not lead to improvements.\n", + "\n", + "If the efficiency is very low (say, 0.1% or lower), the proposal is inefficient. Use a step sampler (see [\"Higher-dimensional fitting\" tutorial](https://johannesbuchner.github.io/UltraNest/example-sine-highd.html))." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Looking inside a interrupted run" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Lets run ultranest for 30 seconds and interrupt it, and see the parameter space it is tackling." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import signal\n", + "def timeout_handler(signum, frame):\n", + " raise TimeoutError()\n", + "old_handler = signal.signal(signal.SIGALRM, timeout_handler) \n", + "signal.alarm(30);" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import ultranest\n", + "\n", + "sampler = ultranest.ReactiveNestedSampler(parameters, log_likelihood, prior_transform,\n", + " wrapped_params=[False, False, False, True])\n", + "\n", + "try:\n", + " sampler.run()\n", + "except TimeoutError:\n", + " print(\"run interrupted!\")\n", + "\n", + "signal.signal(signal.SIGALRM, old_handler);" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Looking inside the current parameter space" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Nested sampling is at this likelihood threshold:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "sampler.Lmin" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Lets see the distribution of live points in the parameter space:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "live_points_p = sampler.transform(sampler.region.u)\n", + "corner.corner(live_points_p, titles=sampler.paramnames, show_titles=True, quiet=True);" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "This does not look trivial, for example P1-t1 plot has two arms. It is not a ellipsoidal contour (which would be the easiest shape).\n", + "\n", + "However, this plot also includes the prior deformation. What ultranest operates on, primarily, is the unit cube. Lets look at the live points distribution in the un-transformed prior space:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "corner.corner(sampler.region.u, show_titles=True, quiet=True);" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Again, you see relatively complicated shapes. This means that the parameters have a complicated relationship with the observables.\n", + "\n", + "You can help ultranest by reparametrizing the parameters, or adding derived parameters, which are more ellipsoidal, and better behaved." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Lets see what models correspond to the current live points:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "plt.plot(t, y, 'o ', ms=14, color='k')\n", + "\n", + "for params in live_points_p:\n", + " plt.plot(t, sine_model1(t, *params), '. ', color=plt.cm.viridis(params[2]/10))\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Here you see two groups of curves, which are highlighted by color-coding by period.\n", + "\n", + "Some models (blue) that have the period as in the data (black circles),\n", + "and some (yellow) just put a straight line through the data.\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "This explains the two arms in the distribution plots above as well." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Reparametrizing" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The sine curve model has the time shift t1 and the period as parameters. Likely, the data will constrain, for example, when the peak occurs, for example." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "So you could add a derived parameter that specifies the time of the first peak. If that is closer to the data, the sampler can take advantage of it." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "You can also see that when A1 is large, B can take a wider range of values, giving a funnel shape:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "plt.scatter(live_points_p[:,0], live_points_p[:,1])\n", + "plt.ylabel('Amplitude (A1)')\n", + "plt.xlabel('Background level (B)');" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "This funnel is even clearer in unit cube space:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "plt.scatter(sampler.region.u[:,0], sampler.region.u[:,1])\n", + "plt.ylabel('Amplitude (A1), untransformed')\n", + "plt.xlabel('Background level (B), untransformed');" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "A different parameterization would be to define a background fraction. Instead of background & amplitude being free parameters, you would have this reparametrized model:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "parameters_reparametrized = ['Bfrac', 'A1', 'P1', 't1']\n", + "\n", + "def prior_transform_reparametrized(cube):\n", + " params = cube.copy()\n", + " # amplitude:\n", + " params[1] = 10**(cube[1] * 3 - 1)\n", + " # background is scaled by params[1]\n", + " params[0] = cube[0] * params[1]\n", + " \n", + " # rest is unchanged\n", + " params[2] = 10**(cube[1] * 2)\n", + " params[3] = cube[3]\n", + " return params" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "This is only a toy example to give you ideas how to investigate the geometries the sampler is currently exploring." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Parallelisation issues" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "If you have any MPI issues, test your MPI first in isolation, by running this command:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "!mpiexec -np 4 python3 -c 'from mpi4py import MPI; print(MPI.COMM_WORLD.Get_rank(), MPI.COMM_WORLD.Get_size())'" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "This should give something like:\n", + "\n", + " 3 4\n", + " 1 4\n", + " 0 4\n", + " 2 4\n", + "\n", + "With the first column randomly. If it gives an output like the above, your MPI is working. If the last column is 1, your cores are not communicating. If you get an error, fix it first." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "If you are seeing slower runtimes with MPI than without, see here: https://johannesbuchner.github.io/UltraNest/performance.html#parallelisation" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Some MPI implementations have bugs, and you can switch to another MPI implementation. Your computing cluster admins may also help you with MPI troubles." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Debugging step sampler quality" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Run with nsteps=1, 2, 4, 8, 16, 32, 64 ... steps and look where the log(Z) value stabilizes." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.4" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/docs/example-intrinsic-distribution.ipynb b/docs/example-intrinsic-distribution.ipynb index e6a1ab53..c058cf87 100644 --- a/docs/example-intrinsic-distribution.ipynb +++ b/docs/example-intrinsic-distribution.ipynb @@ -75,6 +75,15 @@ "Each point represents a possible true solution of that galaxy.\n" ] }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "np.random.seed(42)" + ] + }, { "cell_type": "code", "execution_count": null, @@ -130,7 +139,7 @@ " # we have to convert them to physical scales\n", " \n", " params = cube.copy()\n", - " # let slope go from -3 to +3\n", + " # let mean go from -100 to +100\n", " lo = -100\n", " hi = +100\n", " params[0] = cube[0] * (hi - lo) + lo\n", @@ -255,7 +264,7 @@ "quantile = scipy.stats.norm().cdf(3)\n", "\n", "# look at the value:\n", - "print('scatter is < %.4f km/s at 3 sigma (%.3f%% quantile)' % (scipy.stats.mstats.mquantiles(scatter_samples, quantile), quantile*100))" + "print('scatter is < %.4f km/s at 3 sigma (%.3f%% quantile)' % (np.quantile(scatter_samples, quantile), quantile*100))" ] }, { @@ -303,7 +312,7 @@ ], "metadata": { "kernelspec": { - "display_name": "Python 3", + "display_name": "Python 3 (ipykernel)", "language": "python", "name": "python3" }, @@ -317,9 +326,9 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.6.9" + "version": "3.12.3" } }, "nbformat": 4, - "nbformat_minor": 2 + "nbformat_minor": 4 } diff --git a/docs/example-line.ipynb b/docs/example-line.ipynb index 2ae804ef..74b33068 100644 --- a/docs/example-line.ipynb +++ b/docs/example-line.ipynb @@ -76,8 +76,8 @@ "%matplotlib inline\n", "import matplotlib.pyplot as plt\n", "plt.figure()\n", - "xlabel = 'Bulge mass [log, $M_\\odot$]'\n", - "ylabel = 'Velocity dispersion [km/s]'\n", + "xlabel = r'Bulge mass [log, $M_\\odot$]'\n", + "ylabel = r'Velocity dispersion [km/s]'\n", "plt.xlabel(xlabel)\n", "plt.ylabel(ylabel)\n", "plt.errorbar(x=mB, xerr=mBerr, y=sigma, yerr=sigmaerr, \n", @@ -129,8 +129,8 @@ " plt.scatter(samples_mBi, samples_logsigmai, s=2, marker='x')\n", "\n", "samples = np.array(samples)\n", - "xlabel = 'Bulge mass [log, $M_\\odot$]'\n", - "ylabel = 'Velocity dispersion [log, km/s]'\n", + "xlabel = r'Bulge mass [log, $M_\\odot$]'\n", + "ylabel = r'Velocity dispersion [log, km/s]'\n", "plt.xlabel(xlabel)\n", "plt.ylabel(ylabel)\n" ] @@ -381,7 +381,7 @@ "bins=np.linspace(0.01, 0.2, 64+1)\n", "scatter_samples = result['samples'][:,2]\n", "\n", - "pdf, _ = fastKDE.pdf(scatter_samples, axes=(bins,))\n", + "pdf = fastKDE.pdf_at_points(scatter_samples, list_of_points=bins)\n", "plt.plot(bins, pdf, color='k')\n", "\n", "from ultranest.plot import PredictionBand\n", @@ -390,15 +390,15 @@ "\n", "for weights in result['weighted_samples']['bootstrapped_weights'].transpose():\n", " scatter_samples = resample_equal(result['weighted_samples']['points'][:,2], weights)\n", - " pdf, _ = fastKDE.pdf(scatter_samples, axes=(bins,))\n", + " pdf = fastKDE.pdf_at_points(scatter_samples, list_of_points=bins)\n", " band.add(pdf)\n", "\n", "band.line(ls='--', color='r', alpha=0.5)\n", "band.shade(0.49, color='r', alpha=0.1)\n", "\n", "\n", - "plt.xlabel('$\\sigma$')\n", - "plt.ylabel(\"Posterior probability\")\n", + "plt.xlabel(r'$\\sigma$')\n", + "plt.ylabel(r\"Posterior probability\")\n", "#plt.yscale('log')\n", "plt.ylim(1e-3, 50);\n" ] @@ -650,8 +650,8 @@ "outputs": [], "source": [ "plt.figure()\n", - "plt.xlabel('Black Hole mass [log, $M_\\odot$]')\n", - "plt.ylabel('Bulge mass [log, $M_\\odot$]')\n", + "plt.xlabel(r'Black Hole mass [log, $M_\\odot$]')\n", + "plt.ylabel(r'Bulge mass [log, $M_\\odot$]')\n", "plt.errorbar(y=mB, yerr=mBerr, x=mBH, xerr=[mBHhi-mBH, mBH-mBHlo], \n", " marker='o', ls=' ', color='orange');\n" ] @@ -659,7 +659,7 @@ ], "metadata": { "kernelspec": { - "display_name": "Python 3", + "display_name": "Python 3 (ipykernel)", "language": "python", "name": "python3" }, @@ -673,9 +673,9 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.8.5" + "version": "3.12.3" } }, "nbformat": 4, - "nbformat_minor": 2 + "nbformat_minor": 4 } diff --git a/docs/example-outliers.ipynb b/docs/example-outliers.ipynb index 072be5e5..c631ef96 100644 --- a/docs/example-outliers.ipynb +++ b/docs/example-outliers.ipynb @@ -58,6 +58,7 @@ "source": [ "%matplotlib inline\n", "import matplotlib.pyplot as plt\n", + "np.random.seed(42)\n", "\n", "samples = []\n", "for i in range(n_data):\n", @@ -316,7 +317,7 @@ ], "metadata": { "kernelspec": { - "display_name": "Python 3", + "display_name": "Python 3 (ipykernel)", "language": "python", "name": "python3" }, @@ -330,7 +331,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.8.5" + "version": "3.10.4" } }, "nbformat": 4, diff --git a/docs/example-sine-bayesian-workflow.ipynb b/docs/example-sine-bayesian-workflow.ipynb index 3efb101a..5379449f 100644 --- a/docs/example-sine-bayesian-workflow.ipynb +++ b/docs/example-sine-bayesian-workflow.ipynb @@ -302,7 +302,7 @@ ], "metadata": { "kernelspec": { - "display_name": "Python 3", + "display_name": "Python 3 (ipykernel)", "language": "python", "name": "python3" }, @@ -316,7 +316,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.6.8" + "version": "3.10.4" } }, "nbformat": 4, diff --git a/docs/example-sine-highd.ipynb b/docs/example-sine-highd.ipynb index 2217408c..58c0defc 100644 --- a/docs/example-sine-highd.ipynb +++ b/docs/example-sine-highd.ipynb @@ -203,8 +203,12 @@ " # avoid unnecessary multiple solutions:\n", " # force ordering by period from large to small\n", " if P1 < P2:\n", - " return -1e300\n", - " \n", + " # instead of returning a very low number:\n", + " ## return -1e300\n", + " # which would give a likelihood plateau causing some loss of live points\n", + " # we give a slope towards the \"good\" parameter space:\n", + " return -1e300 * abs(P1 - P2)\n", + "\n", " # compute for each x point, where it should lie in y\n", " y_model = sine_model2(t, B=B, A1=A1, P1=P1, t1=t1, A2=A2, P2=P2, t2=t2)\n", " # compute likelihood\n", @@ -280,7 +284,15 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "The efficiency is very low. This is not just because of the dimensionality of the problem, but also because of the degeneracies. To make progress, lets use a slice sampler:" + "The efficiency is very low. This is not just because of the dimensionality of the problem, but also because of the degeneracies.\n", + "\n", + "To make progress in high-dimensional or otherwise tricky problems, a step sampler can be used.\n", + "\n", + "## Step samplers in UltraNest\n", + "\n", + "To find a replacement live point, step samplers perform a random walk in parameter space. After a number of steps (nsteps), the final point is declared a \"independent\" sample.\n", + "\n", + "There are several step samplers available. Here we will use a [SliceSampler](https://johannesbuchner.github.io/UltraNest/ultranest.html#ultranest.stepsampler.SliceSampler).\n" ] }, { @@ -296,13 +308,12 @@ "\n", "nsteps = 2 * len(parameters2)\n", "# create step sampler:\n", - "sampler2.stepsampler = ultranest.stepsampler.RegionSliceSampler(nsteps=nsteps)\n", - "\n", - "# alternatively, we can let the sample identify the number of steps needed on the fly:\n", - "# This is done by requiring the point to move at least the typical distance\n", - "# between live points, on average.\n", - "#sampler2.stepsampler = ultranest.stepsampler.RegionSliceSampler(nsteps=400, adaptive_nsteps='move-distance')\n", - "\n", + "sampler2.stepsampler = ultranest.stepsampler.SliceSampler(\n", + " nsteps=nsteps,\n", + " generate_direction=ultranest.stepsampler.generate_mixture_random_direction,\n", + " # adaptive_nsteps=False,\n", + " # max_nsteps=400\n", + ")\n", "\n", "# run again:\n", "result2 = sampler2.run(min_num_live_points=400)\n", @@ -313,7 +324,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "The efficiency is now constant (at 1/nsteps)." + "The efficiency is now constant, and proportional to 1/nsteps." ] }, { @@ -396,7 +407,7 @@ "# add 1 sigma quantile\n", "band.shade(color='k', alpha=0.3)\n", "# add wider quantile (0.01 .. 0.99)\n", - "band.shade(q=0.49, color='gray', alpha=0.2)\n", + "band.shade(q=0.49, color='gray', alpha=0.2);\n", "\n" ] }, @@ -424,7 +435,7 @@ "# add 1 sigma quantile\n", "band.shade(color='k', alpha=0.3)\n", "# add wider quantile (0.01 .. 0.99)\n", - "band.shade(q=0.49, color='gray', alpha=0.2)" + "band.shade(q=0.49, color='gray', alpha=0.2);" ] }, { @@ -474,7 +485,7 @@ "metadata": {}, "source": [ "This tells us, assuming both models are equally probable a-priori, that \n", - "the 2-sine model is 150 times more probable to be the true model than the 1-sine model." + "the 2-sine model is >100 times more probable to be the true model than the 1-sine model." ] }, { @@ -494,7 +505,7 @@ ], "metadata": { "kernelspec": { - "display_name": "Python 3", + "display_name": "Python 3 (ipykernel)", "language": "python", "name": "python3" }, @@ -508,7 +519,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.7.4" + "version": "3.10.4" } }, "nbformat": 4, diff --git a/docs/example-sine-modelcomparison.ipynb b/docs/example-sine-modelcomparison.ipynb index 75494638..c0b63ecf 100644 --- a/docs/example-sine-modelcomparison.ipynb +++ b/docs/example-sine-modelcomparison.ipynb @@ -126,7 +126,7 @@ " params[0] = cube[0] * 20 - 10\n", " # let amplitude go from 0.1 to 100\n", " params[1] = 10**(cube[1] * 3 - 1)\n", - " # let period go from 0.3 to 30\n", + " # let period go from 1 to 100\n", " params[2] = 10**(cube[2] * 2)\n", " # let time go from 0 to 1\n", " params[3] = cube[3]\n", @@ -479,7 +479,7 @@ ], "metadata": { "kernelspec": { - "display_name": "Python 3", + "display_name": "Python 3 (ipykernel)", "language": "python", "name": "python3" }, @@ -493,7 +493,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.8.5" + "version": "3.10.4" } }, "nbformat": 4, diff --git a/docs/example-warmstart.ipynb b/docs/example-warmstart.ipynb index abc9998f..9c34227d 100644 --- a/docs/example-warmstart.ipynb +++ b/docs/example-warmstart.ipynb @@ -4,22 +4,33 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "# Tutorial: Warm and hot start for rapid iterations\n", + "# Warm starting\n", "\n", "In this tutorial you will learn:\n", "\n", " - How to play with model variations\n", - " - Warm start: How UltraNest can resume and reuse an existing run, even if you modify the data/likelihood\n", - " - Hot start: How you can make UltraNest skip ahead to the posterior peak\n", + " - Warm start feature: How UltraNest can resume and reuse an existing run, even if you modified the data/likelihood\n", "\n", - "As a simple example, lets say we want to estimate the mean and standard deviation of a sample of points. Over time, more and more points are added." + "As a simple example, lets say we want to fit a black body." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "from numpy import pi, log\n", + "import scipy.stats\n", + "import matplotlib.pyplot as plt" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "## Generate some data" + "## Black body model" ] }, { @@ -28,24 +39,25 @@ "metadata": {}, "outputs": [], "source": [ - "import numpy as np\n", - "from numpy import pi, log\n", - "\n", - "np.random.seed(1)\n", - "Ndata = 200\n", - "mean_true = 42.0\n", - "sigma_true = 0.1\n", - "y = np.random.normal(mean_true, sigma_true, size=Ndata)\n" + "parameters = ['Temperature', 'Amplitude']" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "def black_body_model(wavelength, ampl, T):\n", + " with np.errstate(over='ignore'):\n", + " return ampl / wavelength**5 / (np.exp(1/(wavelength*T)) - 1)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "## Visualise the data\n", - "\n", - "Lets plot the data first to see what is going on:\n", - "\n" + "### Generate some data" ] }, { @@ -54,17 +66,34 @@ "metadata": {}, "outputs": [], "source": [ - "import matplotlib.pyplot as plt\n", - "\n", - "plt.figure(figsize=(10, 5))\n", - "plt.errorbar(x=np.arange(Ndata), y=y, yerr=sigma_true, marker='x', ls=' ');" + "Ndata = 10\n", + "wavelength = np.logspace(1, 2, Ndata)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "np.random.seed(1)\n", + "ampl_true = 42.0\n", + "T_true = 0.01 # in um^-1\n", + "background_true = 1e-9\n", + "y_true = black_body_model(wavelength, ampl_true, T_true)\n", + "sigma_true = y_true * 0.1\n", + "y_obs = np.random.normal(y_true + background_true, sigma_true, size=Ndata)\n", + "sigma = y_true * 0.1" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "We will ingest the data in chunks, with more and more information becoming available to us. Here are the chunks. We will first analyse the orange ones:" + "### Visualise the data\n", + "\n", + "Lets plot the data first to see what is going on:\n", + "\n" ] }, { @@ -74,18 +103,27 @@ "outputs": [], "source": [ "plt.figure(figsize=(10, 5))\n", - "plt.errorbar(x=np.arange(Ndata), y=y, yerr=sigma_true, marker='x', ls=' ')\n", - "plt.errorbar(x=np.arange(Ndata)[:10], y=y[:10], yerr=sigma_true, marker='x', ls=' ')\n", - "ymin, ymax = plt.ylim()\n", - "plt.vlines(np.arange(10, Ndata, 20), ymin, ymax, linestyles='--', color='gray')\n", - "plt.ylim(ymin, ymax);" + "plt.errorbar(x=wavelength, y=y_obs, yerr=sigma, marker='x', ls=' ')\n", + "plt.plot(wavelength, y_true, ':', color='gray')\n", + "plt.ylabel('Spectral flux density [Jy]');\n", + "plt.xlabel(r'Wavelength [$\\mu$m]');\n" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "## Model setup" + "### Prior" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Here we intentionally set very wide priors:\n", + "\n", + "* a uniform prior on temperature, and \n", + "* a very wide log-uniform prior on the normalisation." ] }, { @@ -94,27 +132,39 @@ "metadata": {}, "outputs": [], "source": [ - "from ultranest import ReactiveNestedSampler\n", - "\n", - "parameters = ['mean', 'scatter']\n", - "\n", "def prior_transform(x):\n", - " z = np.empty_like(x)\n", - " z[0] = x[0] * 2000 - 1000\n", - " z[1] = 10**(x[1] * 4 - 2)\n", - " return z\n", + " z = x.copy()\n", + " z[0] = x[0]\n", + " z[1] = 10**(x[1] * 20 - 10)\n", + " return z\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "plt.figure(figsize=(10, 5))\n", + "plt.title(\"Prior predictive checks\")\n", + "plt.errorbar(x=wavelength, y=y_obs, yerr=sigma, marker='x', ls=' ')\n", + "plt.ylim(0, y_obs.max() * 10)\n", "\n", - "import scipy.stats\n", - "def log_likelihood(params):\n", - " mean, sigma = params\n", - " return scipy.stats.norm(mean, sigma).logpdf(yseen).sum()\n" + "for i in range(20):\n", + " T, ampl = prior_transform(np.random.uniform(size=len(parameters)))\n", + " y_predicted = black_body_model(wavelength, ampl, T)\n", + " plt.plot(wavelength, y_predicted, '-', color='gray')\n", + "plt.ylabel('Spectral flux density [Jy]');\n", + "plt.xlabel('Wavelength [$\\\\mu$m]');\n" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "## Adding one new chunk at a time, no warm start" + "### First simple model\n", + "\n", + "Here is a typical gaussian likelihood with our black body function:" ] }, { @@ -123,29 +173,31 @@ "metadata": {}, "outputs": [], "source": [ - "reference_results = []\n", - "\n", - "for i in range(10, Ndata, 20):\n", - " print()\n", - " print(\"Iteration with %d data points\" % i)\n", - " yseen = y[:i]\n", - " sampler_ref = ReactiveNestedSampler(parameters, log_likelihood, prior_transform)\n", - " res_ref = sampler_ref.run(min_num_live_points=400, max_num_improvement_loops=0, viz_callback=None, frac_remain=0.5)\n", - " reference_results.append(res_ref)\n" + "def log_likelihood(params):\n", + " T, ampl = params\n", + " y_predicted = black_body_model(wavelength, ampl, T)\n", + " return scipy.stats.norm(y_predicted, sigma).logpdf(y_obs).sum()\n" ] }, { - "cell_type": "markdown", + "cell_type": "code", + "execution_count": null, "metadata": {}, + "outputs": [], "source": [ - "# Warm start" + "from ultranest import ReactiveNestedSampler\n", + "\n", + "reference_run_folder = 'blackbody-alldata'\n", + "sampler_ref = ReactiveNestedSampler(parameters, log_likelihood, prior_transform, log_dir=reference_run_folder, resume='overwrite')\n", + "results_ref = sampler_ref.run(frac_remain=0.5)\n", + "sampler_ref.print_results()\n" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "## Adding one data point at a time, with warm start" + "### Plot the fit" ] }, { @@ -154,33 +206,25 @@ "metadata": {}, "outputs": [], "source": [ - "results = []\n", - "\n", - "yseen = y[:]\n", - "\n", - "# delete any existing content:\n", - "ReactiveNestedSampler(parameters, log_likelihood, prior_transform,\n", - " log_dir='warmstartdoc', resume='overwrite')\n", - "\n", - "for i in range(10, Ndata, 20):\n", - " print()\n", - " print(\"Iteration with %d data points\" % i)\n", - " \n", - " yseen = y[:i]\n", - " sampler = ReactiveNestedSampler(parameters, log_likelihood, prior_transform,\n", - " log_dir='warmstartdoc', resume='resume-similar',\n", - " warmstart_max_tau=0.5)\n", - " ncall_initial = int(sampler.ncall)\n", - " res = sampler.run(frac_remain=0.5, viz_callback=None)\n", - " results.append((i, res, ncall_initial))\n", - "\n" + "plt.figure(figsize=(10, 5))\n", + "plt.errorbar(x=wavelength, y=y_obs, yerr=sigma, marker='x', ls=' ')\n", + "from ultranest.plot import PredictionBand\n", + "band = PredictionBand(wavelength)\n", + "for T, ampl in results_ref['samples']:\n", + " band.add(black_body_model(wavelength, ampl, T))\n", + "band.line(color='k')\n", + "band.shade(color='k', alpha=0.5)\n", + "plt.ylabel('Spectral flux density [Jy]');\n", + "plt.xlabel('Wavelength [$\\\\mu$m]');\n" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "## Likelihood evaluations saved by warm start" + "## Warm starting a modified model\n", + "\n", + "Lets say we alter our model slightly. We include a small constant background:" ] }, { @@ -189,59 +233,40 @@ "metadata": {}, "outputs": [], "source": [ - "ndim = len(parameters)\n", - "plt.figure(figsize=(10, 10))\n", - "for (i, res, ncall_initial), res_ref in zip(results, reference_results):\n", - " for j in range(ndim):\n", - " plt.subplot(ndim + 2, 1, 1+j)\n", - " plt.ylabel(parameters[j])\n", - " plt.errorbar(x=i, y=res['samples'][:,j].mean(), yerr=res['samples'][:,j].std(), marker='x', color='r')\n", - " plt.errorbar(x=i, y=res_ref['samples'][:,j].mean(), yerr=res_ref['samples'][:,j].std(), marker='x', color='gray')\n", - " \n", - " plt.subplot(ndim + 2, 1, 1+ndim)\n", - " plt.ylabel('$\\log(\\Delta Z)$')\n", - " plt.plot(i, res['logz'] - res_ref['logz'], 'x', color='r')\n", - " plt.subplot(ndim + 2, 1, 1+ndim+1)\n", - " plt.ylabel('Likelihood call fraction')\n", - " plt.plot(i, ((res['ncall'] - ncall_initial) / res_ref['ncall']), 'x', color='r')\n", - " plt.ylim(0, 1)\n", - "\n", - "plt.subplot(ndim + 2, 1, 1)\n", - "plt.hlines(mean_true, 0, i+1, color='k', linestyles=':')\n", - "plt.subplot(ndim + 2, 1, 2)\n", - "plt.hlines(sigma_true, 0, i+1, color='k', linestyles=':')\n" + "def log_likelihood_with_background(params):\n", + " T, ampl = params\n", + " y_predicted = black_body_model(wavelength, ampl, T) + 1e-9\n", + " return scipy.stats.norm(y_predicted, sigma).logpdf(y_obs).sum()\n" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "## Take-aways:\n", - "\n", - "Notice the time saving in the bottom panel by more than half. This benefit is *independent of problem dimension*. The cost savings are higher, the more similar the modified problem is." + "We have the same parameters, and expect results to be only mildly different. So lets use **warm starting**." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "# Hot start" + "Using the previous reference run output file ..." ] }, { - "cell_type": "markdown", + "cell_type": "code", + "execution_count": null, "metadata": {}, + "outputs": [], "source": [ - "We may already know roughly what the posterior looks like. If it is roughly gaussian, we can take advantage of this by running UltraNest on an auxiliary distribution.\n", - "\n", - "The speed-up depends on how the auxiliary distribution is defined. Therefore, this is left to the user, and not automatically derived. The following illustrates how to create a auxiliary distribution and work with it." + "posterior_upoints_file = reference_run_folder + '/chains/weighted_post_untransformed.txt'" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "## Guess a useful covariance" + "We define our accelerated likelihood and prior transform:" ] }, { @@ -250,15 +275,17 @@ "metadata": {}, "outputs": [], "source": [ - "# take result from the second-to-last run\n", - "ref_result = reference_results[-2];" + "from ultranest.integrator import warmstart_from_similar_file\n", + "\n", + "aux_paramnames, aux_log_likelihood, aux_prior_transform, vectorized = warmstart_from_similar_file(\n", + " posterior_upoints_file, parameters, log_likelihood_with_background, prior_transform)\n" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "Luckily, the posterior here is already very gaussian-like:" + "Make accelerated run:" ] }, { @@ -267,28 +294,40 @@ "metadata": {}, "outputs": [], "source": [ - "import corner\n", - "corner.corner(ref_result['samples'], show_titles=True);" + "sampler = ReactiveNestedSampler(aux_paramnames, aux_log_likelihood, aux_prior_transform, vectorized=vectorized)\n", + "res = sampler.run(frac_remain=0.5)" ] }, { - "cell_type": "markdown", + "cell_type": "code", + "execution_count": null, "metadata": {}, + "outputs": [], "source": [ - "**Step 1**: Identify the center and covariance (in u-space, i.e., before the prior transformation).\n", - "\n", - "You can also do this \n", + "plt.figure(figsize=(10, 5))\n", + "plt.errorbar(x=wavelength, y=y_obs, yerr=sigma, marker='x', ls=' ')\n", + "from ultranest.plot import PredictionBand\n", + "band = PredictionBand(wavelength)\n", + "for T, ampl in results_ref['samples']:\n", + " band.add(black_body_model(wavelength, ampl, T))\n", + "band.line(color='k')\n", + "band.shade(color='k', alpha=0.5)\n", "\n", - "* by looking at the data\n", - "* from posterior samples of a previous nested sampling or MCMC run\n", - "* with a minimizer such as [snowline](https://johannesbuchner.github.io/snowline/).\n" + "band = PredictionBand(wavelength)\n", + "for T, ampl, _ in res['samples']:\n", + " band.add(black_body_model(wavelength, ampl, T))\n", + "band.line(color='orange')\n", + "band.shade(color='orange', alpha=0.5)\n", + "plt.plot(wavelength, y_true, ':', color='gray')\n", + "plt.ylabel('Spectral flux density [Jy]');\n", + "plt.xlabel('Wavelength [$\\\\mu$m]');\n" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "We demonstrate the second method here:" + "## Speed-up" ] }, { @@ -297,37 +336,60 @@ "metadata": {}, "outputs": [], "source": [ - "indices = np.random.choice(len(ref_result['weighted_samples']['weights']), p=ref_result['weighted_samples']['weights'], size=10000)\n", - "u_posterior = ref_result['weighted_samples']['upoints'][indices,:]\n", - "ctr = u_posterior.mean(axis=0)\n", - "cov = np.cov(u_posterior, rowvar=False)\n", + "print(\"Speed-up of warm-start: %d%%\" % ((results_ref['ncall'] / res['ncall'] - 1)*100))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The cost savings are higher, the more similar the posterior of the modified run is to the original run. This speed-up increases drastically if you have highly informative posteriors.\n", + "This benefit is *independent of problem dimension*." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## How it works & Limitations\n", "\n", - "print(\"center in unit cube coordinates:\", ctr)\n", - "print(\"center in physical coordinates:\", prior_transform(ctr))\n", - "print(\"covariance:\", cov)\n", + "Warm-starting works by deforming the parameter space. The prior transform function is adjusted, and the adjustment is removed by reweighting the likelihood function, to produce the same posterior.\n", + "To make this work, posterior samples from the unit cube space are required. The deformation uses a factorized auxiliary distribution, based on marginal posterior quantiles.\n", "\n", - "invcov = np.linalg.inv(cov)\n", - "print(\"precision matrix:\", invcov)" + "The weighted_post_untransformed.txt file from a hot-started run cannot be used. This is because it has a deformation already applied.\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Read the full documentation at\n", + "* [warmstart_from_similar_file](https://johannesbuchner.github.io/UltraNest/ultranest.html#ultranest.integrator.warmstart_from_similar_file ) and\n", + "* the underlying [get_auxiliary_contbox_parameterization](https://johannesbuchner.github.io/UltraNest/ultranest.html#ultranest.hotstart.get_auxiliary_contbox_parameterization) function" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "Let us intentionally show the case where a poor distribution is chosen:" + "## Warm starting from posterior samples" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "**Step 2**: Define the auxiliary distribution\n", "\n", - "This is always the same, once you have chosen a center and covariance.\n", - "Here we use a multivariate Student-t distribution with one degree of freedom.\n", + "If you already have posterior samples, then you can create an appropriate weighted_post_untransformed.txt file.\n", + "However, the inverse of the prior transformation has to be applied.\n", + "\n", + "In some cases, this is easy to do analytically, e.g., for uniform priors it is just a scaling.\n", + "\n", + "### When the transform cannot be inverted easily\n", + "\n", + "The following code works for arbitrary, factorized priors (as in the blackbody example in this notebook), for an arbitrary number of parameters.\n", "\n", - "This allows heavier-tailed posterior distributions than a Gaussian,\n", - "and is more forgiving if we mis-estimated the center or the covariance." + "Lets start with our posterior samples. These could be obtained posterior samples from MCMC, or generated from the parameter errors quoted in a paper. Here we take it from the reference run:" ] }, { @@ -336,11 +398,20 @@ "metadata": {}, "outputs": [], "source": [ - "from ultranest.hotstart import get_extended_auxiliary_problem\n", + "posterior_samples = results_ref['samples']\n", "\n", - "aux_log_likelihood, aux_transform = get_extended_auxiliary_problem(\n", - " log_likelihood, prior_transform, ctr, invcov, \n", - " enlargement_factor=len(parameters)**0.5, df=2)\n" + "plt.scatter(posterior_samples[:,0], posterior_samples[:,1]);\n", + "plt.xlabel('%s (p-space)' % parameters[0])\n", + "plt.ylabel('%s (p-space)' % parameters[1]);\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Lets have a look how our unit-cube prior transform works:\n", + "\n", + "The first parameter has a uniform prior, the other a log-uniform prior." ] }, { @@ -349,12 +420,8 @@ "metadata": {}, "outputs": [], "source": [ - "#aux_parameters = ['aux_%d' % (i + 1) for i, p in enumerate(parameters)]\n", - "aux_sampler = ReactiveNestedSampler(\n", - " parameters, aux_log_likelihood, transform=aux_transform,\n", - " derived_param_names=['aux_logweight'],\n", - ")\n", - "aux_results = aux_sampler.run(frac_remain=0.5, viz_callback=None)" + "uguess = np.linspace(1e-6, 1-1e-6, 40000)\n", + "pguess = np.array([prior_transform(ui * np.ones(len(parameters))) for ui in uguess])" ] }, { @@ -363,38 +430,21 @@ "metadata": {}, "outputs": [], "source": [ - "from getdist import MCSamples, plots\n", - "\n", - "aux_dist_samples_full = np.array([aux_transform(np.random.uniform(size=len(parameters))) for i in range(10000)])\n", - "aux_dist_samples = aux_dist_samples_full[aux_dist_samples_full[:,-1] > -1e100,:-1]\n", - "\n", - "samples_o = MCSamples(samples=ref_result['samples'],\n", - " names=ref_result['paramnames'],\n", - " label='Cold start',\n", - " settings=dict(smooth_scale_2D=3), sampler='nested')\n", - "samples_a = MCSamples(samples=aux_dist_samples,\n", - " names=ref_result['paramnames'],\n", - " label='Auxiliary distribution',\n", - " settings=dict(smooth_scale_2D=1), sampler='nested')\n", - "samples_g = MCSamples(samples=aux_results['samples'][:,:-1],\n", - " names=aux_results['paramnames'][:-1],\n", - " label='Hot start',\n", - " settings=dict(smooth_scale_2D=3), sampler='nested')\n", - "\n", - "mcsamples = [samples_o, samples_a, samples_g]\n", - "\n", - "g = plots.get_subplot_plotter(width_inch=8)\n", - "g.settings.num_plot_contours = 3\n", - "g.triangle_plot(mcsamples, filled=False, contour_colors=plt.cm.Set1.colors,\n", - " param_limits=dict(zip(parameters, [(41.9, 42.1), (0, 0.2)])))\n", - "\n" + "plt.subplot(2, 1, 1)\n", + "plt.plot(uguess, pguess[:,0])\n", + "plt.xlabel('u-space (%s)' % parameters[0])\n", + "plt.ylabel('p-space (%s)' % parameters[0]);\n", + "plt.subplot(2, 1, 2)\n", + "plt.plot(uguess, pguess[:,1])\n", + "plt.xlabel('u-space (%s)' % parameters[1])\n", + "plt.ylabel('p-space (%s)' % parameters[1]);" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "In a good run, most auxiliary weights should be small (<1). If they are not, you may need to increase the enlargement_factor." + "Here we convert the posterior samples to u-space, by finding the unit-cube value by optimization." ] }, { @@ -403,22 +453,53 @@ "metadata": {}, "outputs": [], "source": [ - "plt.hist(aux_results['samples'][:,-1], bins=40)\n", - "plt.xlabel(\"ln(weights)\");" + "import scipy.optimize" ] }, { - "cell_type": "markdown", + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import tqdm" + ] + }, + { + "cell_type": "code", + "execution_count": null, "metadata": {}, + "outputs": [], "source": [ - "## Speed-up by hot start" + "nparams = len(parameters)\n", + "u = np.ones(nparams) * 0.5\n", + "stdevs = posterior_samples.std(axis=0)\n", + "\n", + "def minfunc(ui, i, u, pi):\n", + " if not 0 < ui < 1: return 1e100\n", + " u[i] = ui\n", + " p = prior_transform(u)\n", + " return (p[i] - pi)**2\n", + "\n", + "usamples = np.empty((len(posterior_samples), nparams))\n", + "for j, sample in enumerate(tqdm.tqdm(posterior_samples)):\n", + " for i, param in enumerate(parameters):\n", + " ui0 = np.interp(sample[i], pguess[:,i], uguess)\n", + " result = scipy.optimize.minimize_scalar(\n", + " minfunc, \n", + " args=(i, u, sample[i]), \n", + " method='brent',\n", + " bracket=(ui0 - 1e-4, ui0, ui0 + 1e-4),\n", + " tol=0.001 * stdevs[i],\n", + " )\n", + " usamples[j,i] = result.x" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "Assuming we already obtained the covariance and mean for free, what is the additional cost of the hot start?" + "Lets see whether our untransformed (u-space) posterior samples are correct:" ] }, { @@ -427,14 +508,53 @@ "metadata": {}, "outputs": [], "source": [ - "print(\"auxiliary sampler used %(ncall)d likelihood calls\" % aux_results)" + "weights = results_ref['weighted_samples']['weights']\n", + "i = np.random.choice(len(weights), p=weights, size=1000)\n", + "plt.scatter(results_ref['weighted_samples']['upoints'][i,0], results_ref['weighted_samples']['upoints'][i,1], \n", + " color='gray', label='reference run');\n", + "\n", + "plt.scatter(usamples[:,0], usamples[:,1], label='modified run, usamples reconstructed', marker='x', alpha=0.5)\n", + "plt.xlabel('u-space (%s)' % parameters[0])\n", + "plt.ylabel('u-space (%s)' % parameters[1])\n", + "plt.legend();" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "This looks like great agreement! We successfully untransformed the posterior samples to u-space." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Writing a run file for warm start" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "Compare this to the full run with the same number of data points." + "We write a weighted_post_untransformed.txt file based on our untransformed posterior samples. Since these are equally weighted, the first two columns are constants." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "weights = np.ones((len(usamples), 1)) / len(usamples)\n", + "logl = np.zeros(len(usamples)).reshape((-1, 1))\n", + "\n", + "np.savetxt(\n", + " 'custom-weighted_post_untransformed.txt',\n", + " np.hstack((weights, logl, usamples)),\n", + " header=' '.join(['weight', 'logl'] + parameters),\n", + " fmt='%f'\n", + ")" ] }, { @@ -443,14 +563,14 @@ "metadata": {}, "outputs": [], "source": [ - "print(\"Speedup factor of hot start: %.1f\" % (reference_results[-1]['ncall'] / aux_results['ncall']))" + "!head custom-weighted_post_untransformed.txt" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "This speed-up increases drastically if you have highly informative posteriors." + "We can now point the warmstart_from_similar_file function at this file." ] }, { @@ -459,23 +579,23 @@ "source": [ "## Conclusion\n", "\n", - "* Warm start allows accelerated computation based on a different but similar UltraNest run. \n", - "* Hot start allows accelerated computation based on already approximately knowing the posterior peak.\n", - "\n", - "These feature allows you to:\n", + "Warm start allows accelerated computation based on already knowing the posterior peak approximately. This allows you to:\n", "\n", "* vary the data (change the analysis pipeline)\n", "* vary model assumptions \n", "\n", - "**without needing to start the computation from scratch** (potentially costly).\n", + "without needing to start the computation from scratch (potentially costly).\n", + "\n", + "These features are experimental and feedback is appreciated. It is recommended to do a full, clean run to obtain final, reliable results before publication.\n", "\n", - "These features are experimental and feedback is appreciated. It is recommended to do a full, clean run to obtain final, reliable results before publication.\n" + "References:\n", + " * \"SuperNest\" by Aleksandr Petrosyan and Will Handley https://arxiv.org/abs/2212.01760 \n" ] } ], "metadata": { "kernelspec": { - "display_name": "Python 3", + "display_name": "Python 3 (ipykernel)", "language": "python", "name": "python3" }, @@ -489,9 +609,9 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.8.5" + "version": "3.12.3" } }, "nbformat": 4, - "nbformat_minor": 2 + "nbformat_minor": 4 } diff --git a/docs/gauss.py b/docs/gauss.py new file mode 100644 index 00000000..d757c929 --- /dev/null +++ b/docs/gauss.py @@ -0,0 +1,61 @@ +import argparse +import numpy as np +from numpy import log + +# define command line arguments: +parser = argparse.ArgumentParser() + +parser.add_argument('--x_dim', type=int, default=2, + help="Dimensionality") +parser.add_argument("--num_live_points", type=int, default=400) +parser.add_argument('--sigma', type=float, default=0.1) +parser.add_argument('--slice', action='store_true') +parser.add_argument('--slice_steps', type=int, default=100) +parser.add_argument('--log_dir', type=str, default='logs/loggauss') + +args = parser.parse_args() + +ndim = args.x_dim +sigma = args.sigma +width = max(0, 1 - 5 * sigma) +centers = (np.sin(np.arange(ndim)/2.) * width + 1.) / 2. + +# Here, we implement a vectorized loglikelihood, which can +# process many points at the same time. This reduces function calls. +def loglike(theta): + like = -0.5 * (((theta - centers)/sigma)**2).sum(axis=1) - 0.5 * np.log(2 * np.pi * sigma**2) * ndim + return like + +def transform(x): + return x + +paramnames = ['param%d' % (i+1) for i in range(ndim)] + +# set up nested sampler: + +from ultranest import ReactiveNestedSampler + +sampler = ReactiveNestedSampler(paramnames, loglike, transform=transform, + log_dir=args.log_dir + 'RNS-%dd' % ndim, resume=True, + vectorized=True) + +if args.slice: + # set up step sampler. Here, we use a differential evolution slice sampler: + import ultranest.stepsampler + sampler.stepsampler = ultranest.stepsampler.SliceSampler( + nsteps=args.slice_steps, + generate_direction=ultranest.stepsampler.generate_mixture_random_direction, + ) + +# run sampler, with a few custom arguments: +sampler.run(dlogz=0.5 + 0.1 * ndim, + update_interval_volume_fraction=0.4 if ndim > 20 else 0.2, + max_num_improvement_loops=3, + min_num_live_points=args.num_live_points) + +sampler.print_results() + +if args.slice: + sampler.stepsampler.plot(filename = args.log_dir + 'RNS-%dd/stepsampler_stats_regionslice.pdf' % ndim) + +sampler.plot() diff --git a/docs/index.rst b/docs/index.rst index 10c6d6dd..bd7930ed 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -11,7 +11,7 @@ Welcome to UltraNest's documentation! using-ultranest.ipynb priors.ipynb performance - modules + API issues contributing history @@ -27,6 +27,8 @@ Welcome to UltraNest's documentation! example-line.ipynb example-outliers.ipynb example-sine-bayesian-workflow.ipynb + example-warmstart.ipynb + debugging.ipynb .. include:: ../README.rst diff --git a/docs/issues.rst b/docs/issues.rst index 2e931b91..61f01798 100644 --- a/docs/issues.rst +++ b/docs/issues.rst @@ -16,21 +16,32 @@ Opening a github issue is preferred, because then other people can find the ques How do I suppress the output? ----------------------------- -To suppress the logging to stdout, you can configure your own logger:: +To suppress the live point visualisations, set ``viz_callback=False`` in ``sampler.run()``. + +To suppress the status line, set ``show_status=False`` in `sampler.run()``. + +See the documentation of :py:meth:`ultranest.ReactiveNestedSampler.run()`. + +To suppress the logging to stderr, set up a logging handler:: import logging logger = logging.getLogger("ultranest") handler = logging.StreamHandler(sys.stdout) handler.setLevel(logging.WARNING) - formatter = logging.Formatter('[{}] [%(levelname)s] %(message)s'.format(module_name)) + formatter = logging.Formatter('[ultranest] [%(levelname)s] %(message)s') handler.setFormatter(formatter) logger.addHandler(handler) + logger.setLevel(logging.WARNING) -You may want to alter the above to log to a file only. See the logging python module docs. +You may want to alter the above to log to a file instead. See the `logging python module `_ docs. -To suppress the live point visualisations, set ``viz_callback=False`` in ``sampler.run()``. +To completely turn off logging, you can use:: + + import logging + logger = logging.getLogger("ultranest") + logger.addHandler(logging.NullHandler()) + logger.setLevel(logging.WARNING) -To suppress the status line, set ``show_status=False`` in ``sampler.run()``. How should I choose the number of live points? ----------------------------------------------- @@ -280,7 +291,7 @@ How should I cite UltraNest? The main algorithm (MLFriends) is described in: -* Buchner, J. (2014): `A statistical test for Nested Sampling algorithms `_ (`bibtex `__) +* Buchner, J. (2014): `A statistical test for Nested Sampling algorithms `_ (`bibtex `__) * Buchner, J. (2019): `Collaborative Nested Sampling: Big Data versus Complex Physical Models `_ (`bibtex `__) The UltraNest software package is presented in: diff --git a/docs/modoverview.py b/docs/modoverview.py new file mode 100644 index 00000000..268a94c3 --- /dev/null +++ b/docs/modoverview.py @@ -0,0 +1,40 @@ +import importlib + +sections = [ + ('Modules commonly used directly', ['integrator', 'plot', 'stepsampler', 'popstepsampler', 'calibrator', 'solvecompat', 'hotstart']), + ('Internally used modules', ['mlfriends', 'netiter', 'ordertest', 'stepfuncs', 'store', 'viz']), + ('Experimental modules, no guarantees', ['dychmc', 'dyhmc', 'flatnuts', 'pathsampler', 'samplingpath']), +] + +fout = open('API.rst', 'w') +fout.write("""API +=== + +`Full API documentation on one page `_ + +The main interface is :py:class:`ultranest.integrator.ReactiveNestedSampler`, +also available as `ultranest.ReactiveNestedSampler`. + +""") + +for section, modules in sections: + fout.write("\n%s:\n%s\n\n" % (section, '-'*80)) + for mod in modules: + moddoc = importlib.import_module('ultranest.%s' % mod).__doc__ + modtitle = moddoc.strip().split('\n')[0] + + print('%-15s: %s' % (mod, modtitle)) + fout.write(" * :py:mod:`ultranest.%s`: %s\n" % (mod, modtitle)) + +fout.write(""" + +Alphabetical list of submodules +------------------------------- + +.. toctree:: + :maxdepth: 2 + + ultranest + + +""") diff --git a/docs/performance.rst b/docs/performance.rst index 214e826b..4e075531 100644 --- a/docs/performance.rst +++ b/docs/performance.rst @@ -1,7 +1,7 @@ .. _performance: ==================================== -Tour of the features +Features ==================================== @@ -9,6 +9,7 @@ This tutorial demonstrates: * How to make a program that uses nested sampling * How to store and resume runs +* The meaning of the output files * How to use UltraNest in 100 dimensions * How to speed up likelihood functions with vectorization * How to write a program with UltraNest @@ -22,40 +23,8 @@ and analyses it. To understand it, have a look first the `Basic usage `_ page. -.. code-block:: python3 - :caption: simple.py - :name: simple.py - - import scipy.stats - - paramnames = ['param1', 'param2', 'param3'] - centers = [0.4, 0.5, 0.6] - sigma = 0.1 - - def transform(cube): - return cube - - def loglike(theta): - return scipy.stats.norm(centers, sigma).logpdf(theta).sum() - - from ultranest import ReactiveNestedSampler - sampler = ReactiveNestedSampler(paramnames, loglike, transform=transform, - log_dir='my_gauss', # folder where to store files - resume=True, # whether to resume from there (otherwise start from scratch) - ) - - sampler.run( - min_num_live_points=400, - dlogz=0.5, # desired accuracy on logz - min_ess=400, # number of effective samples - update_interval_iter_fraction=0.4, # how often to update region - max_num_improvement_loops=3, # how many times to go back and improve - ) - - sampler.print_results() - - sampler.plot() - sampler.plot_trace() +.. literalinclude:: simple.py + :language: python3 Running this, you should see outputs like: @@ -110,30 +79,77 @@ and the parameter constraints: param2 0.500 +- 0.099 param3 0.602 +- 0.098 -In the folder my_gauss you can find useful files: - -* **debug.log**: log file of the run. Include when reporting bugs. -* **results/points.hdf5**: file storing all sampled points. Used for resuming. -* **chains/equal_weighted_post.txt**: posterior samples. Each column corresponds to one parameter. -* **chains/weighted_post.txt**: weighted posterior samples. Weight, -loglikelihood, parameter value (d times). getdist compatible. -* **chains/weighted_post.paramnames**: Parameter names -* **info/results.json**: all results (logz, etc.) as a json dictionary -* **plots/corner.pdf**: corner plot -* **plots/run.pdf**: diagnostic plot showing integration progress -* **plots/trace.pdf**: diagnostic plot showing problem structure - Some features worth noting here: -* Key diagnostic plots are included. -* The program can resume from crashes -- even if run with a different number of live points. * UltraNest shows what it is currently exploring. This is especially useful for debugging models. +* Key diagnostic plots are included in the output folder (see below). +* The program can resume from crashes -- even if run with a different number of live points. -Lets go to some more advanced usage examples: Integrating a 100-dimensional gaussian. -For that, we have to make a few modifications. +Output files +============ + +If a `log_dir` directory was specified, you will find these files: + +* debug.log: A debug log of the run + + * Please attach it or the stdout output when you open a `Github issue `_. + * This contains the efficiency and progress of the sampling. + +* info folder: machine-readable summaries of the posterior + + * **post_summary.csv**: for each parameter: mean, std, median, upper and lower 1 sigma error. Can be read with `pandas.read_csv `_. + * **results.json**: Contains detailed output of the nested sampling run, with all the same keys as the result dictionary in :py:meth:`ultranest.integrator.ReactiveNestedSampler.run`, except for ``samples`` and ``weighted_samples`` (as the sample information is saved in separate files - see the following entries in this list). Can be read with `json.load `_. + +* chains: machine-readable chains + + * **equal_weighted_post.txt**: equally weighted posterior samples (similar to a Markov chain). Each column corresponds to one parameter. + + * You can make a corner plot from this. + + * weighted_post.txt: posterior samples with a weight attached. + + * This is made by nested sampling directly, and the above is produced from this. However, carrying the weights around is cumbersome. + * getdist compatible. columns are Weight, -loglikelihood, parameter value (d times). + + * weighted_post_untransformed.txt: same as above, but in coordinates before the prior transformation. + * run.txt: for each iteration, ln(z) and error, ln(volume), number of live points, log-likelihood threshold, posterior point weight (likelihood x volume) and insertion rank of newly sampled point. + +* plots: Visualisations (by plot functions) + + * corner.pdf: corner/pairs plot of the marginal and conditional parameter posteriors. + + * Useful for investigating degeneracies and which parameters were learned. + + * trace.pdf: diagnostic plot showing problem structure + + * Visualises how each parameter's range was reduced as the nested sampling proceeds. + * Color indicates where the bulk of the posterior lies. + * Useful to understand the structure of the inference problem, and which parameters are learned first. + + * run.pdf: diagnostic plot showing integration progress + + * Visualises how the number of live points, likelihood and posterior weight evolved through the nested sampling run. + * Visualises the evidence integration and its uncertainty. + +All of the above can be written, but are never read, by ultranest.ReactiveNestedSampler. The only file used to +read the state of a previous run is: + +* results/points.hdf5: file storing all sampled points. Used for resuming. + + * this is an internal file. + * ncalls: number of likelihood calls + * points: the columns are: likelihood threshold under which the point was sampled, likelihood of the point, a quality indicator (0 for MLFriends, otherwise the number of steps in the step sampler), u-space (unit cube) coordinates, p-space (transformed parameters) coordinates. + +You can safely store additional files and plots in the sub-folders. Speed ups =========== +Lets go to some more advanced usage examples: Integrating a 100-dimensional gaussian. +For that, we have to make a few modifications to enhance the +**computational speed**. Enhancing the **algorithmic speed** (number of likelihood evaluations +needed per iterations) is discussed in the next section. + Implementing a gaussian likelihood can be done in a few ways. Very slow: @@ -172,78 +188,19 @@ To use this function, pass ``vectorized=True`` to ReactiveNestedSampler. Lets see how this looks like in a full program. Vectorized full program -================================ +------------------------ Below is a Python program that implements a gaussian likelihood, and allows the user to specify the problem dimension and a few sampler parameters. -.. code-block:: python3 - :caption: gauss.py - :name: gauss.py - - import argparse - import numpy as np - from numpy import log - - # define command line arguments: - parser = argparse.ArgumentParser() - - parser.add_argument('--x_dim', type=int, default=2, - help="Dimensionality") - parser.add_argument("--num_live_points", type=int, default=400) - parser.add_argument('--sigma', type=float, default=0.1) - parser.add_argument('--slice', action='store_true') - parser.add_argument('--slice_steps', type=int, default=100) - parser.add_argument('--log_dir', type=str, default='logs/loggauss') - - args = parser.parse_args() - - ndim = args.x_dim - sigma = args.sigma - width = max(0, 1 - 5 * sigma) - centers = (np.sin(np.arange(ndim)/2.) * width + 1.) / 2. - - # Here, we implement a vectorized loglikelihood, which can - # process many points at the same time. This reduces function calls. - def loglike(theta): - like = -0.5 * (((theta - centers)/sigma)**2).sum(axis=1) - 0.5 * np.log(2 * np.pi * sigma**2) * ndim - return like - - def transform(x): - return x - - paramnames = ['param%d' % (i+1) for i in range(ndim)] - - # set up nested sampler: - - from ultranest import ReactiveNestedSampler - - sampler = ReactiveNestedSampler(paramnames, loglike, transform=transform, - log_dir=args.log_dir + 'RNS-%dd' % ndim, resume=True, - vectorized=True) - - if args.slice: - # set up step sampler. Here, we use a slice sampler: - import ultranest.stepsampler - sampler.stepsampler = ultranest.stepsampler.RegionSliceSampler(nsteps=args.slice_steps) - - # run sampler, with a few custom arguments: - sampler.run(dlogz=0.5 + 0.1 * ndim, - update_interval_iter_fraction=0.4 if ndim > 20 else 0.2, - max_num_improvement_loops=3, - min_num_live_points=args.num_live_points) - - sampler.print_results() - - if args.slice: - sampler.stepsampler.plot(filename = args.log_dir + 'RNS-%dd/stepsampler_stats_regionslice.pdf' % ndim) - - sampler.plot() +.. literalinclude:: gauss.py + :language: python3 Note that our likelihood is vectorized, and we pass ``vectorized=True``. A similar program is included in the git repository as *examples/testasymgauss.py*. + High-dimensional models ======================== @@ -377,13 +334,14 @@ The integral is given as:: This result is close to the analytic value (0) on infinite bounds (the prior boundaries slightly increase the result). -We can test whether the slice sampler is good enough by halving -``slice_steps``. The logZ estimate should ideally be consistent. +We can test whether the slice sampler is good enough by doubling +the number of steps, until the ln(Z) estimate is stable. -Using multiple cores +Parallelisation ==================== -Depending on your numpy installation, the above may already use multiple CPUs. +Your likelihood function may already be using multiple cores, +whether your intended to or not, due to underlying libraries (e.g., numpy). You can control this with the OMP_NUM_THREADS environment variable: .. code-block:: bash @@ -391,26 +349,50 @@ You can control this with the OMP_NUM_THREADS environment variable: # avoid automatic parallelisation export OMP_NUM_THREADS=1 +If the likelihood is not parallelised, ultranest can parallelize +its execution to multiple cores. + +Using multiple cores +-------------------- + To use multiple processors and cores, scaling UltraNest all the way to large computing clusters, you can parallelise the program with MPI: -No code changes are required. You need to install MPI (for example, OpenMPI) and mpi4py (pip install mpi4py). -Then run: +* No code changes are required. +* You need to install MPI (for example, OpenMPI) and mpi4py (pip install mpi4py). +* Then run your script with mpiexec: .. code-block:: bash mpiexec -np 4 python3 gauss.py --x_dim=100 --num_live_points=400 --slice --slice_steps=100 +This launches four scripts which are started in parallel, and ultranest +coordinates them. + +Use as many scripts as processors. If memory is a concern, look into shared memory solutions. + +GPU-acceleration +==================== + +Some models today use probabilistic programming languages, such as JAX, +which allows fast model evaluations on GPUs and CPUs. + +UltraNest supports such models with vectorization (see above). + +For high-dimensional, cheap, vectorized models, the +:py:mod:`popstepsampler` implements vectorized versions. + More features =================== -To find more features such as ... +To find more features and details such as ... * Circular/wrapped parameter spaces * Model comparison of empirical and physical models * Quantifying posterior uncertainty * Visualisation and interoperation with getdist, pandas, matplotlib, ... * Using in a Jupyter notebook +* all the step samplers and slice samplers available ... see the tutorials! diff --git a/docs/priors.ipynb b/docs/priors.ipynb index 9ed0cbb3..e27ea144 100644 --- a/docs/priors.ipynb +++ b/docs/priors.ipynb @@ -23,8 +23,7 @@ "source": [ "import numpy as np\n", "import scipy.stats\n", - "import matplotlib.pyplot as plt\n", - "%matplotlib inline" + "import matplotlib.pyplot as plt" ] }, { @@ -63,7 +62,7 @@ "\n", "We invert the cumulative probability distribution mapping quantiles (0...1) to the corresponding model parameter value.\n", "\n", - "Lets start with the uniform distribution." + "Lets start with the uniform distribution:" ] }, { @@ -130,7 +129,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## The unit hypercube\n", + "## Specifying priors\n", "\n", "Lets specify a prior for UltraNest with multiple parameters:\n", "\n", @@ -166,7 +165,8 @@ "Some recommendations:\n", "\n", "* [scipy.stats](https://docs.scipy.org/doc/scipy/reference/stats.html#continuous-distributions) provides many 1-d distributions that can be used like this.\n", - "* avoid building scipy.stats objects in the transform, because this is slow -- build them outside first, then only invoke the .ppf method in the transform.\n" + "* avoid building scipy.stats objects in the transform, because this is slow -- build them outside first, then only invoke the .ppf method in the transform.\n", + "* If you are looking for a distribution that is not implemented yet, try to follow a random number generator recipe (see the Dirichlet prior for an example, below).\n" ] }, { @@ -175,7 +175,9 @@ "source": [ "## Dependent priors\n", "\n", - "In some cases, a previous experiment gives informative priors which we want to incorporate, and they may be inter-dependent. For example, consider a two-dimensional gaussian prior distribution:\n" + "### Incorporating covariances\n", + "\n", + "In some cases, a previous experiment gives informative priors which we want to incorporate, and they may be inter-dependent. For example, consider a two-dimensional gaussian prior distribution.\n" ] }, { @@ -253,14 +255,18 @@ "plt.contourf(X, Y, Z, cmap='magma_r')\n", "plt.plot(samples[:,0], samples[:,1], 'o', mew=1, mfc='w', mec='k')\n", "plt.xlabel('Parameter 1')\n", - "plt.ylabel('Parameter 2');\n" + "plt.ylabel('Parameter 2');" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "A similar effect can be achieved by defining transforms in sequence (this is a different prior though):" + "#### Conditional prior approach\n", + "\n", + "Another approach is to sample the second parameter conditional on the first parameter, already transformed. This is akin to Gibbs sampling.\n", + "\n", + "For an example, we have a first parameter with a Gaussian prior, and a second parameter, with a Gaussian prior centred around the first parameter's value. Therefore, its value shifts with the first parameter:" ] }, { @@ -273,22 +279,66 @@ "gauss2 = scipy.stats.norm(0, 0.1)\n", "\n", "\n", - "def transform_correlated(quantiles):\n", + "def transform_correlated_gibbs(quantiles):\n", " parameters = np.empty_like(quantiles)\n", " # first parameter is independent\n", - " parameters[0] = gauss1.ppf(quantiles[0])\n", + " parameters[:,0] = gauss1.ppf(quantiles[:,0])\n", " # second parameter depends on first parameter, here with a shift\n", - " parameters[1] = parameters[0] + gauss2.ppf(quantiles[0])\n", + " parameters[:,1] = parameters[:,0] + gauss2.ppf(quantiles[:,1])\n", " return parameters" ] }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "samples = transform_correlated_gibbs(np.random.uniform(0, 1, size=(100, 2)))\n", + "\n", + "plt.figure()\n", + "plt.title('Gibbs prior')\n", + "plt.plot(samples[:,0], samples[:,1], 'o', mew=1, mfc='w', mec='k')\n", + "plt.xlabel('Parameter 1')\n", + "plt.ylabel('Parameter 2');" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "As you can see, we also achieve a correlated prior. However, this is different from the previous example." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Complicated constraints and rejection in the likelihood" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "In some situations, you may have more constraints than parameters, such as:\n", + "\n", + " parameter_1_lower < parameter_1 < parameter_1_upper\n", + " parameter_2_lower < parameter_2 < parameter_2_upper\n", + " parameter_1 + parameter_2 < constant\n", + "\n", + "In that case, move either the first two or the last constraint into the likelihood function, whichever option is more relaxed (i.e., causes fewer rejections). This is achieved by returning a very low likelihood (e.g., -1e100), when the constraint is not met.\n", + "\n", + "It is beneficial for the sampler if you can add a slight slope towards the good region of the constraint. e.g., -1e100 * (1 + parameter_1 + parameter_2) or similar. This is because if you use the exact same constant, this is a likelihood plateau, and the live points have to be reduced until the plateau is traversed." + ] + }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Non-analytic priors\n", "\n", - "Sometimes, the prior may not be easily invertable. For example, when it is given as posterior samples from a previous analysis. I\n", + "Sometimes, the prior may not be easily invertable. For example, when it is given as posterior samples from a previous analysis. Lets say as a prior, we want a posterior from another experiment that looks like this:\n", "\n" ] }, @@ -301,14 +351,14 @@ "posterior_samples = np.hstack((np.random.uniform(0, 3, 2000), np.random.normal(3, 0.2, 2000)))\n", "\n", "plt.figure(figsize=(4,2))\n", - "plt.hist(posterior_samples, histtype='step', bins=100);\n" + "plt.hist(posterior_samples, histtype='step', bins=100);" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "In this case, you can compute the cumulative distribution numerically and invert it:" + "In this case, we can compute the cumulative distribution numerically and invert it. Lets try implementing this and sampling from it:" ] }, { @@ -327,7 +377,7 @@ "samples = transform_histogram(np.random.uniform(size=1000))\n", "plt.figure(figsize=(4,2))\n", "plt.hist(posterior_samples, histtype='step', bins=100, density=True);\n", - "plt.hist(samples, histtype='step', bins=100, density=True);\n" + "plt.hist(samples, histtype='step', bins=100, density=True);" ] }, { @@ -366,7 +416,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "Lets have a look at the samples:" + "Lets have a look at the samples, and whether the three fractions look uniform and sum up to 1:" ] }, { @@ -423,7 +473,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.8.5" + "version": "3.8.10" } }, "nbformat": 4, diff --git a/docs/simple.py b/docs/simple.py new file mode 100644 index 00000000..f6116ff0 --- /dev/null +++ b/docs/simple.py @@ -0,0 +1,30 @@ +import scipy.stats + +paramnames = ['param1', 'param2', 'param3'] +centers = [0.4, 0.5, 0.6] +sigma = 0.1 + +def transform(cube): + return cube + +def loglike(theta): + return scipy.stats.norm(centers, sigma).logpdf(theta).sum() + +from ultranest import ReactiveNestedSampler +sampler = ReactiveNestedSampler(paramnames, loglike, transform=transform, + log_dir='my_gauss', # folder where to store files + resume=True, # whether to resume from there (otherwise start from scratch) +) + +sampler.run( + min_num_live_points=400, + dlogz=0.5, # desired accuracy on logz + min_ess=400, # number of effective samples + update_interval_volume_fraction=0.4, # how often to update region + max_num_improvement_loops=3, # how many times to go back and improve +) + +sampler.print_results() + +sampler.plot() +sampler.plot_trace() diff --git a/docs/static/mcmc-demo/lib/canvas-5-polyfill.js b/docs/static/mcmc-demo/lib/canvas-5-polyfill.js new file mode 100644 index 00000000..f24b5821 --- /dev/null +++ b/docs/static/mcmc-demo/lib/canvas-5-polyfill.js @@ -0,0 +1,12 @@ +/** + * Copyright 2014 Google Inc. All rights reserved. + * + * Use of this source code is governed by a BSD-style + * license that can be found in the LICENSE file. + * + * @fileoverview Description of this file. + * + * A polyfill for HTML Canvas features, including + * Path2D support. + */ +void 0==CanvasRenderingContext2D.prototype.ellipse&&(CanvasRenderingContext2D.prototype.ellipse=function(t,r,n,e,o,a,i,s){this.save(),this.translate(t,r),this.rotate(o),this.scale(n,e),this.arc(0,0,1,a,i,s),this.restore()}),"function"!=typeof Path2D&&!function(){function t(t){if(this.ops_=[],void 0!=t)if("string"==typeof t)try{this.ops_=parser.parse(t)}catch(r){}else{if(!t.hasOwnProperty("ops_"))throw"Error: "+typeof t+"is not a valid argument to Path";this.ops_=t.ops_.slice(0)}}function r(t){return function(){this.ops_.push({type:t,args:Array.prototype.slice.call(arguments,0)})}}parser=function(){function t(t,r){function n(){this.constructor=t}n.prototype=r.prototype,t.prototype=new n}function r(t,r,n,e,o,a){this.message=t,this.expected=r,this.found=n,this.offset=e,this.line=o,this.column=a,this.name="SyntaxError"}function n(t){function n(r){function n(r,n,e){var o,a;for(o=n;e>o;o++)a=t.charAt(o),"\n"===a?(r.seenCR||r.line++,r.column=1,r.seenCR=!1):"\r"===a||"\u2028"===a||"\u2029"===a?(r.line++,r.column=1,r.seenCR=!0):(r.column++,r.seenCR=!1)}return Ar!==r&&(Ar>r&&(Ar=0,xr={line:1,column:1,seenCR:!1}),n(xr,Ar,r),Ar=r),xr}function e(t){mr>Cr||(Cr>mr&&(mr=Cr,Pr=[]),Pr.push(t))}function o(e,o,a){function i(t){var r=1;for(t.sort(function(t,r){return t.descriptionr.description?1:0});r1?i.slice(0,-1).join(", ")+" or "+i[t.length-1]:i[0],o=r?'"'+n(r)+'"':"end of input","Expected "+e+" but "+o+" found."}var u=n(a),c=a1)for(var e=r[1],o=0;o1&&(r*=l,n*=l,c=Math.pow(r,2),p=Math.pow(n,2));var f=Math.sqrt((c*p-c*u[1]-p*u[0])/(c*u[1]+p*u[0]));o==a&&(f*=-1);var h=it(f,[r*s[1]/n,-n*s[0]/r]),v=st(nt(h,e),ot(t,i)),g=[(s[0]-h[0])/r,(s[1]-h[1])/n],y=[(-1*s[0]-h[0])/r,(-1*s[1]-h[1])/n],d=tt([1,0],g),C=tt(g,y),_=d,A=d+C;Dr.push({type:"save",args:[]},{type:"translate",args:[v[0],v[1]]},{type:"rotate",args:[e]},{type:"scale",args:[r,n]},{type:"arc",args:[0,0,1,_,A,1-a]},{type:"restore",args:[]})}var ct,pt=arguments.length>1?arguments[1]:{},lt={},ft={svg_path:a},ht=a,vt=lt,gt=null,yt=function(t){return Dr},dt=/^[Mm]/,Ct={type:"class",value:"[Mm]",description:"[Mm]"},_t=function(t,r){var n=t;Tr&&(n="M",Tr=!1),Dr.push({type:"moveTo",args:U(n,r[0])});for(var e=1;en;n++){var o=r.ops_[n];CanvasRenderingContext2D.prototype[o.type].apply(this,o.args)}original_fill.apply(this,Array.prototype.slice.call(arguments,1))}else original_fill.apply(this,arguments)},CanvasRenderingContext2D.prototype.stroke=function(r){if(r instanceof t){this.beginPath();for(var n=0,e=r.ops_.length;e>n;n++){var o=r.ops_[n];CanvasRenderingContext2D.prototype[o.type].apply(this,o.args)}original_stroke.call(this)}else original_stroke.call(this)},CanvasRenderingContext2D.prototype.clip=function(r){if(r instanceof t){this.beginPath();for(var n=0,e=r.ops_.length;e>n;n++){var o=r.ops_[n];CanvasRenderingContext2D.prototype[o.type].apply(this,o.args)}original_clip.apply(this,Array.prototype.slice.call(arguments,1))}else original_clip.apply(this,arguments)},CanvasRenderingContext2D.prototype.isPointInPath=function(r){if(r instanceof t){this.beginPath();for(var n=0,e=r.ops_.length;e>n;n++){var o=r.ops_[n];CanvasRenderingContext2D.prototype[o.type].apply(this,o.args)}return original_is_point_in_path.apply(this,Array.prototype.slice.call(arguments,1))}return original_is_point_in_path.apply(this,arguments)},CanvasRenderingContext2D.prototype.isPointInStroke=function(r){if(r instanceof t){this.beginPath();for(var n=0,e=r.ops_.length;e>n;n++){var o=r.ops_[n];CanvasRenderingContext2D.prototype[o.type].apply(this,o.args)}return original_is_point_in_stroke.apply(this,Array.prototype.slice.call(arguments,1))}return original_is_point_in_stroke.apply(this,arguments)},Path2D=t}(); \ No newline at end of file diff --git a/docs/static/mcmc-demo/lib/conrec.min.js b/docs/static/mcmc-demo/lib/conrec.min.js new file mode 100644 index 00000000..ff5796a2 --- /dev/null +++ b/docs/static/mcmc-demo/lib/conrec.min.js @@ -0,0 +1,66 @@ +/** + * Copyright (c) 2010, Jason Davies. + * + * All rights reserved. This code is based on Bradley White's Java version, + * which is in turn based on Nicholas Yue's C++ version, which in turn is based + * on Paul D. Bourke's original Fortran version. See below for the respective + * copyright notices. + * + * See http://local.wasp.uwa.edu.au/~pbourke/papers/conrec/ for the original + * paper by Paul D. Bourke. + * + * The vector conversion code is based on http://apptree.net/conrec.htm by + * Graham Cox. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * * Neither the name of the nor the + * names of its contributors may be used to endorse or promote products + * derived from this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL BE LIABLE FOR ANY + * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND + * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF + * THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +/* + * Copyright (c) 1996-1997 Nicholas Yue + * + * This software is copyrighted by Nicholas Yue. This code is based on Paul D. + * Bourke's CONREC.F routine. + * + * The authors hereby grant permission to use, copy, and distribute this + * software and its documentation for any purpose, provided that existing + * copyright notices are retained in all copies and that this notice is + * included verbatim in any distributions. Additionally, the authors grant + * permission to modify this software and its documentation for any purpose, + * provided that such modifications are not distributed without the explicit + * consent of the authors and that existing copyright notices are retained in + * all copies. Some of the algorithms implemented by this software are + * patented, observe all applicable patent law. + * + * IN NO EVENT SHALL THE AUTHORS OR DISTRIBUTORS BE LIABLE TO ANY PARTY FOR + * DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING OUT + * OF THE USE OF THIS SOFTWARE, ITS DOCUMENTATION, OR ANY DERIVATIVES THEREOF, + * EVEN IF THE AUTHORS HAVE BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + * + * THE AUTHORS AND DISTRIBUTORS SPECIFICALLY DISCLAIM ANY WARRANTIES, + * INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT. THIS SOFTWARE IS + * PROVIDED ON AN "AS IS" BASIS, AND THE AUTHORS AND DISTRIBUTORS HAVE NO + * OBLIGATION TO PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR + * MODIFICATIONS. + */ +!function(e){function t(e,t){var a=e.x-t.x,r=e.y-t.y;return i>a*a+r*r}function a(e){for(var t=e.head;t;){var a=t.next;t.next=t.prev,t.prev=a,t=a}var a=e.head;e.head=e.tail,e.tail=a}function r(e){this.level=e,this.s=null,this.count=0}function n(e){if(e)this.drawContour=e;else{var t=this;t.contours={},this.drawContour=function(e,a,n,i,s,h){var l=t.contours[h];l||(l=t.contours[h]=new r(s)),l.addSegment({x:e,y:a},{x:n,y:i})},this.contourList=function(){var e=[],a=t.contours;for(var r in a)for(var n=a[r].s,i=a[r].level;n;){var s=n.head,h=[];for(h.level=i,h.k=r;s&&s.p;)h.push(s.p),s=s.next;e.push(h),n=n.next}return e.sort(function(e,t){return e.k-t.k}),e}}this.h=new Array(5),this.sh=new Array(5),this.xh=new Array(5),this.yh=new Array(5)}e.Conrec=n;var i=1e-10;r.prototype.remove_seq=function(e){e.prev?e.prev.next=e.next:this.s=e.next,e.next&&(e.next.prev=e.prev),--this.count},r.prototype.addSegment=function(e,r){for(var n=this.s,i=null,s=null,h=!1,l=!1;n&&(null==i&&(t(e,n.head.p)?(i=n,h=!0):t(e,n.tail.p)&&(i=n)),null==s&&(t(r,n.head.p)?(s=n,l=!0):t(r,n.tail.p)&&(s=n)),null==s||null==i);)n=n.next;var o=(null!=i?1:0)|(null!=s?2:0);switch(o){case 0:var u={p:e,prev:null},v={p:r,next:null};u.next=v,v.prev=u,i={head:u,tail:v,next:this.s,prev:null,closed:!1},this.s&&(this.s.prev=i),this.s=i,++this.count;break;case 1:var c={p:r};h?(c.next=i.head,c.prev=null,i.head.prev=c,i.head=c):(c.next=null,c.prev=i.tail,i.tail.next=c,i.tail=c);break;case 2:var c={p:e};l?(c.next=s.head,c.prev=null,s.head.prev=c,s.head=c):(c.next=null,c.prev=s.tail,s.tail.next=c,s.tail=c);break;case 3:if(i===s){var c={p:i.tail.p,next:i.head,prev:null};i.head.prev=c,i.head=c,i.closed=!0;break}switch((h?1:0)|(l?2:0)){case 0:a(i);case 1:s.tail.next=i.head,i.head.prev=s.tail,s.tail=i.tail,this.remove_seq(i);break;case 3:a(i);case 2:i.tail.next=s.head,s.head.prev=i.tail,i.tail=s.tail,this.remove_seq(s)}}},n.prototype.contour=function(e,t,a,r,n,s,h,l,o){var u=this.h,v=this.sh,c=this.xh,p=this.yh,x=this.drawContour;this.contours={};for(var d,f,k,y,b,w,m=function(e,t){return(u[t]*c[e]-u[e]*c[t])/(u[t]-u[e])},M=function(e,t){return(u[t]*p[e]-u[e]*p[t])/(u[t]-u[e])},A=0,C=0,q=0,_=0,g=[0,1,1,0],S=[0,0,1,1],L=[[[0,0,8],[0,2,5],[7,6,9]],[[0,3,4],[1,3,1],[4,3,0]],[[9,6,7],[5,2,0],[8,0,0]]],j=n-1;j>=r;j--)for(var z=t;a-1>=z;z++){var B,D;if(B=Math.min(e[z][j],e[z][j+1]),D=Math.min(e[z+1][j],e[z+1][j+1]),b=Math.min(B,D),B=Math.max(e[z][j],e[z][j+1]),D=Math.max(e[z+1][j],e[z+1][j+1]),w=Math.max(B,D),w>=o[0]&&b<=o[l-1])for(var E=0;l>E;E++)if(o[E]>=b&&o[E]<=w){for(var F=4;F>=0;F--)F>0?(u[F]=e[z+g[F-1]][j+S[F-1]]-o[E],c[F]=s[z+g[F-1]],p[F]=h[j+S[F-1]]):(u[0]=.25*(u[1]+u[2]+u[3]+u[4]),c[0]=.5*(s[z]+s[z+1]),p[0]=.5*(h[j]+h[j+1])),u[F]>i?v[F]=1:u[F]<-i?v[F]=-1:v[F]=0;for(F=1;4>=F;F++)if(d=F,f=0,k=4!=F?F+1:1,y=L[v[d]+1][v[f]+1][v[k]+1],0!=y){switch(y){case 1:A=c[d],q=p[d],C=c[f],_=p[f];break;case 2:A=c[f],q=p[f],C=c[k],_=p[k];break;case 3:A=c[k],q=p[k],C=c[d],_=p[d];break;case 4:A=c[d],q=p[d],C=m(f,k),_=M(f,k);break;case 5:A=c[f],q=p[f],C=m(k,d),_=M(k,d);break;case 6:A=c[k],q=p[k],C=m(d,f),_=M(d,f);break;case 7:A=m(d,f),q=M(d,f),C=m(f,k),_=M(f,k);break;case 8:A=m(f,k),q=M(f,k),C=m(k,d),_=M(k,d);break;case 9:A=m(k,d),q=M(k,d),C=m(d,f),_=M(d,f)}x(A,q,C,_,o[E],E)}}}}}("undefined"!=typeof exports?exports:window); \ No newline at end of file diff --git a/docs/static/mcmc-demo/lib/dat.gui.min.js b/docs/static/mcmc-demo/lib/dat.gui.min.js new file mode 100644 index 00000000..35733d96 --- /dev/null +++ b/docs/static/mcmc-demo/lib/dat.gui.min.js @@ -0,0 +1,95 @@ +/** + * dat-gui JavaScript Controller Library + * http://code.google.com/p/dat-gui + * + * Copyright 2011 Data Arts Team, Google Creative Lab + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ +var dat=dat||{};dat.gui=dat.gui||{};dat.utils=dat.utils||{};dat.controllers=dat.controllers||{};dat.dom=dat.dom||{};dat.color=dat.color||{};dat.utils.css=function(){return{load:function(f,a){a=a||document;var d=a.createElement("link");d.type="text/css";d.rel="stylesheet";d.href=f;a.getElementsByTagName("head")[0].appendChild(d)},inject:function(f,a){a=a||document;var d=document.createElement("style");d.type="text/css";d.innerHTML=f;a.getElementsByTagName("head")[0].appendChild(d)}}}(); +dat.utils.common=function(){var f=Array.prototype.forEach,a=Array.prototype.slice;return{BREAK:{},extend:function(d){this.each(a.call(arguments,1),function(a){for(var c in a)this.isUndefined(a[c])||(d[c]=a[c])},this);return d},defaults:function(d){this.each(a.call(arguments,1),function(a){for(var c in a)this.isUndefined(d[c])&&(d[c]=a[c])},this);return d},compose:function(){var d=a.call(arguments);return function(){for(var e=a.call(arguments),c=d.length-1;0<=c;c--)e=[d[c].apply(this,e)];return e[0]}}, +each:function(a,e,c){if(a)if(f&&a.forEach&&a.forEach===f)a.forEach(e,c);else if(a.length===a.length+0)for(var b=0,p=a.length;bthis.__max&&(a=this.__max);void 0!==this.__step&&0!=a%this.__step&&(a=Math.round(a/this.__step)*this.__step);return e.superclass.prototype.setValue.call(this,a)},min:function(a){this.__min=a;return this},max:function(a){this.__max=a;return this},step:function(a){this.__impliedStep=this.__step=a;this.__precision=d(a);return this}});return e}(dat.controllers.Controller,dat.utils.common); +dat.controllers.NumberControllerBox=function(f,a,d){var e=function(c,b,f){function q(){var a=parseFloat(n.__input.value);d.isNaN(a)||n.setValue(a)}function l(a){var b=u-a.clientY;n.setValue(n.getValue()+b*n.__impliedStep);u=a.clientY}function r(){a.unbind(window,"mousemove",l);a.unbind(window,"mouseup",r)}this.__truncationSuspended=!1;e.superclass.call(this,c,b,f);var n=this,u;this.__input=document.createElement("input");this.__input.setAttribute("type","text");a.bind(this.__input,"change",q);a.bind(this.__input, +"blur",function(){q();n.__onFinishChange&&n.__onFinishChange.call(n,n.getValue())});a.bind(this.__input,"mousedown",function(b){a.bind(window,"mousemove",l);a.bind(window,"mouseup",r);u=b.clientY});a.bind(this.__input,"keydown",function(a){13===a.keyCode&&(n.__truncationSuspended=!0,this.blur(),n.__truncationSuspended=!1)});this.updateDisplay();this.domElement.appendChild(this.__input)};e.superclass=f;d.extend(e.prototype,f.prototype,{updateDisplay:function(){var a=this.__input,b;if(this.__truncationSuspended)b= +this.getValue();else{b=this.getValue();var d=Math.pow(10,this.__precision);b=Math.round(b*d)/d}a.value=b;return e.superclass.prototype.updateDisplay.call(this)}});return e}(dat.controllers.NumberController,dat.dom.dom,dat.utils.common); +dat.controllers.NumberControllerSlider=function(f,a,d,e,c){function b(a,b,c,e,d){return e+(a-b)/(c-b)*(d-e)}var p=function(c,e,d,f,u){function A(c){c.preventDefault();var e=a.getOffset(k.__background),d=a.getWidth(k.__background);k.setValue(b(c.clientX,e.left,e.left+d,k.__min,k.__max));return!1}function g(){a.unbind(window,"mousemove",A);a.unbind(window,"mouseup",g);k.__onFinishChange&&k.__onFinishChange.call(k,k.getValue())}p.superclass.call(this,c,e,{min:d,max:f,step:u});var k=this;this.__background= +document.createElement("div");this.__foreground=document.createElement("div");a.bind(this.__background,"mousedown",function(b){a.bind(window,"mousemove",A);a.bind(window,"mouseup",g);A(b)});a.addClass(this.__background,"slider");a.addClass(this.__foreground,"slider-fg");this.updateDisplay();this.__background.appendChild(this.__foreground);this.domElement.appendChild(this.__background)};p.superclass=f;p.useDefaultStyles=function(){d.inject(c)};e.extend(p.prototype,f.prototype,{updateDisplay:function(){var a= +(this.getValue()-this.__min)/(this.__max-this.__min);this.__foreground.style.width=100*a+"%";return p.superclass.prototype.updateDisplay.call(this)}});return p}(dat.controllers.NumberController,dat.dom.dom,dat.utils.css,dat.utils.common,"/**\n * dat-gui JavaScript Controller Library\n * http://code.google.com/p/dat-gui\n *\n * Copyright 2011 Data Arts Team, Google Creative Lab\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n */\n\n.slider {\n box-shadow: inset 0 2px 4px rgba(0,0,0,0.15);\n height: 1em;\n border-radius: 1em;\n background-color: #eee;\n padding: 0 0.5em;\n overflow: hidden;\n}\n\n.slider-fg {\n padding: 1px 0 2px 0;\n background-color: #aaa;\n height: 1em;\n margin-left: -0.5em;\n padding-right: 0.5em;\n border-radius: 1em 0 0 1em;\n}\n\n.slider-fg:after {\n display: inline-block;\n border-radius: 1em;\n background-color: #fff;\n border: 1px solid #aaa;\n content: '';\n float: right;\n margin-right: -1em;\n margin-top: -1px;\n height: 0.9em;\n width: 0.9em;\n}"); +dat.controllers.FunctionController=function(f,a,d){var e=function(c,b,d){e.superclass.call(this,c,b);var f=this;this.__button=document.createElement("div");this.__button.innerHTML=void 0===d?"Fire":d;a.bind(this.__button,"click",function(a){a.preventDefault();f.fire();return!1});a.addClass(this.__button,"button");this.domElement.appendChild(this.__button)};e.superclass=f;d.extend(e.prototype,f.prototype,{fire:function(){this.__onChange&&this.__onChange.call(this);this.getValue().call(this.object); +this.__onFinishChange&&this.__onFinishChange.call(this,this.getValue())}});return e}(dat.controllers.Controller,dat.dom.dom,dat.utils.common); +dat.controllers.BooleanController=function(f,a,d){var e=function(c,b){e.superclass.call(this,c,b);var d=this;this.__prev=this.getValue();this.__checkbox=document.createElement("input");this.__checkbox.setAttribute("type","checkbox");a.bind(this.__checkbox,"change",function(){d.setValue(!d.__prev)},!1);this.domElement.appendChild(this.__checkbox);this.updateDisplay()};e.superclass=f;d.extend(e.prototype,f.prototype,{setValue:function(a){a=e.superclass.prototype.setValue.call(this,a);this.__onFinishChange&& +this.__onFinishChange.call(this,this.getValue());this.__prev=this.getValue();return a},updateDisplay:function(){!0===this.getValue()?(this.__checkbox.setAttribute("checked","checked"),this.__checkbox.checked=!0):this.__checkbox.checked=!1;return e.superclass.prototype.updateDisplay.call(this)}});return e}(dat.controllers.Controller,dat.dom.dom,dat.utils.common); +dat.color.toString=function(f){return function(a){if(1==a.a||f.isUndefined(a.a)){for(a=a.hex.toString(16);6>a.length;)a="0"+a;return"#"+a}return"rgba("+Math.round(a.r)+","+Math.round(a.g)+","+Math.round(a.b)+","+a.a+")"}}(dat.utils.common); +dat.color.interpret=function(f,a){var d,e,c=[{litmus:a.isString,conversions:{THREE_CHAR_HEX:{read:function(a){a=a.match(/^#([A-F0-9])([A-F0-9])([A-F0-9])$/i);return null===a?!1:{space:"HEX",hex:parseInt("0x"+a[1].toString()+a[1].toString()+a[2].toString()+a[2].toString()+a[3].toString()+a[3].toString())}},write:f},SIX_CHAR_HEX:{read:function(a){a=a.match(/^#([A-F0-9]{6})$/i);return null===a?!1:{space:"HEX",hex:parseInt("0x"+a[1].toString())}},write:f},CSS_RGB:{read:function(a){a=a.match(/^rgb\(\s*(.+)\s*,\s*(.+)\s*,\s*(.+)\s*\)/); +return null===a?!1:{space:"RGB",r:parseFloat(a[1]),g:parseFloat(a[2]),b:parseFloat(a[3])}},write:f},CSS_RGBA:{read:function(a){a=a.match(/^rgba\(\s*(.+)\s*,\s*(.+)\s*,\s*(.+)\s*\,\s*(.+)\s*\)/);return null===a?!1:{space:"RGB",r:parseFloat(a[1]),g:parseFloat(a[2]),b:parseFloat(a[3]),a:parseFloat(a[4])}},write:f}}},{litmus:a.isNumber,conversions:{HEX:{read:function(a){return{space:"HEX",hex:a,conversionName:"HEX"}},write:function(a){return a.hex}}}},{litmus:a.isArray,conversions:{RGB_ARRAY:{read:function(a){return 3!= +a.length?!1:{space:"RGB",r:a[0],g:a[1],b:a[2]}},write:function(a){return[a.r,a.g,a.b]}},RGBA_ARRAY:{read:function(a){return 4!=a.length?!1:{space:"RGB",r:a[0],g:a[1],b:a[2],a:a[3]}},write:function(a){return[a.r,a.g,a.b,a.a]}}}},{litmus:a.isObject,conversions:{RGBA_OBJ:{read:function(b){return a.isNumber(b.r)&&a.isNumber(b.g)&&a.isNumber(b.b)&&a.isNumber(b.a)?{space:"RGB",r:b.r,g:b.g,b:b.b,a:b.a}:!1},write:function(a){return{r:a.r,g:a.g,b:a.b,a:a.a}}},RGB_OBJ:{read:function(b){return a.isNumber(b.r)&& +a.isNumber(b.g)&&a.isNumber(b.b)?{space:"RGB",r:b.r,g:b.g,b:b.b}:!1},write:function(a){return{r:a.r,g:a.g,b:a.b}}},HSVA_OBJ:{read:function(b){return a.isNumber(b.h)&&a.isNumber(b.s)&&a.isNumber(b.v)&&a.isNumber(b.a)?{space:"HSV",h:b.h,s:b.s,v:b.v,a:b.a}:!1},write:function(a){return{h:a.h,s:a.s,v:a.v,a:a.a}}},HSV_OBJ:{read:function(b){return a.isNumber(b.h)&&a.isNumber(b.s)&&a.isNumber(b.v)?{space:"HSV",h:b.h,s:b.s,v:b.v}:!1},write:function(a){return{h:a.h,s:a.s,v:a.v}}}}}];return function(){e=!1; +var b=1\n\n Here\'s the new load parameter for your GUI\'s constructor:\n\n \n\n
\n\n Automatically save\n values to localStorage on exit.\n\n
The values saved to localStorage will\n override those passed to dat.GUI\'s constructor. This makes it\n easier to work incrementally, but localStorage is fragile,\n and your friends may not see the same values you do.\n \n
\n \n
\n\n', +".dg {\n /** Clear list styles */\n /* Auto-place container */\n /* Auto-placed GUI's */\n /* Line items that don't contain folders. */\n /** Folder names */\n /** Hides closed items */\n /** Controller row */\n /** Name-half (left) */\n /** Controller-half (right) */\n /** Controller placement */\n /** Shorter number boxes when slider is present. */\n /** Ensure the entire boolean and function row shows a hand */ }\n .dg ul {\n list-style: none;\n margin: 0;\n padding: 0;\n width: 100%;\n clear: both; }\n .dg.ac {\n position: fixed;\n top: 0;\n left: 0;\n right: 0;\n height: 0;\n z-index: 0; }\n .dg:not(.ac) .main {\n /** Exclude mains in ac so that we don't hide close button */\n overflow: hidden; }\n .dg.main {\n -webkit-transition: opacity 0.1s linear;\n -o-transition: opacity 0.1s linear;\n -moz-transition: opacity 0.1s linear;\n transition: opacity 0.1s linear; }\n .dg.main.taller-than-window {\n overflow-y: auto; }\n .dg.main.taller-than-window .close-button {\n opacity: 1;\n /* TODO, these are style notes */\n margin-top: -1px;\n border-top: 1px solid #2c2c2c; }\n .dg.main ul.closed .close-button {\n opacity: 1 !important; }\n .dg.main:hover .close-button,\n .dg.main .close-button.drag {\n opacity: 1; }\n .dg.main .close-button {\n /*opacity: 0;*/\n -webkit-transition: opacity 0.1s linear;\n -o-transition: opacity 0.1s linear;\n -moz-transition: opacity 0.1s linear;\n transition: opacity 0.1s linear;\n border: 0;\n position: absolute;\n line-height: 19px;\n height: 20px;\n /* TODO, these are style notes */\n cursor: pointer;\n text-align: center;\n background-color: #000; }\n .dg.main .close-button:hover {\n background-color: #111; }\n .dg.a {\n float: right;\n margin-right: 15px;\n overflow-x: hidden; }\n .dg.a.has-save > ul {\n margin-top: 27px; }\n .dg.a.has-save > ul.closed {\n margin-top: 0; }\n .dg.a .save-row {\n position: fixed;\n top: 0;\n z-index: 1002; }\n .dg li {\n -webkit-transition: height 0.1s ease-out;\n -o-transition: height 0.1s ease-out;\n -moz-transition: height 0.1s ease-out;\n transition: height 0.1s ease-out; }\n .dg li:not(.folder) {\n cursor: auto;\n height: 27px;\n line-height: 27px;\n overflow: hidden;\n padding: 0 4px 0 5px; }\n .dg li.folder {\n padding: 0;\n border-left: 4px solid rgba(0, 0, 0, 0); }\n .dg li.title {\n cursor: pointer;\n margin-left: -4px; }\n .dg .closed li:not(.title),\n .dg .closed ul li,\n .dg .closed ul li > * {\n height: 0;\n overflow: hidden;\n border: 0; }\n .dg .cr {\n clear: both;\n padding-left: 3px;\n height: 27px; }\n .dg .property-name {\n cursor: default;\n float: left;\n clear: left;\n width: 40%;\n overflow: hidden;\n text-overflow: ellipsis; }\n .dg .c {\n float: left;\n width: 60%; }\n .dg .c input[type=text] {\n border: 0;\n margin-top: 4px;\n padding: 3px;\n width: 100%;\n float: right; }\n .dg .has-slider input[type=text] {\n width: 30%;\n /*display: none;*/\n margin-left: 0; }\n .dg .slider {\n float: left;\n width: 66%;\n margin-left: -5px;\n margin-right: 0;\n height: 19px;\n margin-top: 4px; }\n .dg .slider-fg {\n height: 100%; }\n .dg .c input[type=checkbox] {\n margin-top: 9px; }\n .dg .c select {\n margin-top: 5px; }\n .dg .cr.function,\n .dg .cr.function .property-name,\n .dg .cr.function *,\n .dg .cr.boolean,\n .dg .cr.boolean * {\n cursor: pointer; }\n .dg .selector {\n display: none;\n position: absolute;\n margin-left: -9px;\n margin-top: 23px;\n z-index: 10; }\n .dg .c:hover .selector,\n .dg .selector.drag {\n display: block; }\n .dg li.save-row {\n padding: 0; }\n .dg li.save-row .button {\n display: inline-block;\n padding: 0px 6px; }\n .dg.dialogue {\n background-color: #222;\n width: 460px;\n padding: 15px;\n font-size: 13px;\n line-height: 15px; }\n\n/* TODO Separate style and structure */\n#dg-new-constructor {\n padding: 10px;\n color: #222;\n font-family: Monaco, monospace;\n font-size: 10px;\n border: 0;\n resize: none;\n box-shadow: inset 1px 1px 1px #888;\n word-wrap: break-word;\n margin: 12px 0;\n display: block;\n width: 440px;\n overflow-y: scroll;\n height: 100px;\n position: relative; }\n\n#dg-local-explain {\n display: none;\n font-size: 11px;\n line-height: 17px;\n border-radius: 3px;\n background-color: #333;\n padding: 8px;\n margin-top: 10px; }\n #dg-local-explain code {\n font-size: 10px; }\n\n#dat-gui-save-locally {\n display: none; }\n\n/** Main type */\n.dg {\n color: #eee;\n font: 11px 'Lucida Grande', sans-serif;\n text-shadow: 0 -1px 0 #111;\n /** Auto place */\n /* Controller row,
  • */\n /** Controllers */ }\n .dg.main {\n /** Scrollbar */ }\n .dg.main::-webkit-scrollbar {\n width: 5px;\n background: #1a1a1a; }\n .dg.main::-webkit-scrollbar-corner {\n height: 0;\n display: none; }\n .dg.main::-webkit-scrollbar-thumb {\n border-radius: 5px;\n background: #676767; }\n .dg li:not(.folder) {\n background: #1a1a1a;\n border-bottom: 1px solid #2c2c2c; }\n .dg li.save-row {\n line-height: 25px;\n background: #dad5cb;\n border: 0; }\n .dg li.save-row select {\n margin-left: 5px;\n width: 108px; }\n .dg li.save-row .button {\n margin-left: 5px;\n margin-top: 1px;\n border-radius: 2px;\n font-size: 9px;\n line-height: 7px;\n padding: 4px 4px 5px 4px;\n background: #c5bdad;\n color: #fff;\n text-shadow: 0 1px 0 #b0a58f;\n box-shadow: 0 -1px 0 #b0a58f;\n cursor: pointer; }\n .dg li.save-row .button.gears {\n background: #c5bdad url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAsAAAANCAYAAAB/9ZQ7AAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAQJJREFUeNpiYKAU/P//PwGIC/ApCABiBSAW+I8AClAcgKxQ4T9hoMAEUrxx2QSGN6+egDX+/vWT4e7N82AMYoPAx/evwWoYoSYbACX2s7KxCxzcsezDh3evFoDEBYTEEqycggWAzA9AuUSQQgeYPa9fPv6/YWm/Acx5IPb7ty/fw+QZblw67vDs8R0YHyQhgObx+yAJkBqmG5dPPDh1aPOGR/eugW0G4vlIoTIfyFcA+QekhhHJhPdQxbiAIguMBTQZrPD7108M6roWYDFQiIAAv6Aow/1bFwXgis+f2LUAynwoIaNcz8XNx3Dl7MEJUDGQpx9gtQ8YCueB+D26OECAAQDadt7e46D42QAAAABJRU5ErkJggg==) 2px 1px no-repeat;\n height: 7px;\n width: 8px; }\n .dg li.save-row .button:hover {\n background-color: #bab19e;\n box-shadow: 0 -1px 0 #b0a58f; }\n .dg li.folder {\n border-bottom: 0; }\n .dg li.title {\n padding-left: 16px;\n background: black url(data:image/gif;base64,R0lGODlhBQAFAJEAAP////Pz8////////yH5BAEAAAIALAAAAAAFAAUAAAIIlI+hKgFxoCgAOw==) 6px 10px no-repeat;\n cursor: pointer;\n border-bottom: 1px solid rgba(255, 255, 255, 0.2); }\n .dg .closed li.title {\n background-image: url(data:image/gif;base64,R0lGODlhBQAFAJEAAP////Pz8////////yH5BAEAAAIALAAAAAAFAAUAAAIIlGIWqMCbWAEAOw==); }\n .dg .cr.boolean {\n border-left: 3px solid #806787; }\n .dg .cr.function {\n border-left: 3px solid #e61d5f; }\n .dg .cr.number {\n border-left: 3px solid #2fa1d6; }\n .dg .cr.number input[type=text] {\n color: #2fa1d6; }\n .dg .cr.string {\n border-left: 3px solid #1ed36f; }\n .dg .cr.string input[type=text] {\n color: #1ed36f; }\n .dg .cr.function:hover, .dg .cr.boolean:hover {\n background: #111; }\n .dg .c input[type=text] {\n background: #303030;\n outline: none; }\n .dg .c input[type=text]:hover {\n background: #3c3c3c; }\n .dg .c input[type=text]:focus {\n background: #494949;\n color: #fff; }\n .dg .c .slider {\n background: #303030;\n cursor: ew-resize; }\n .dg .c .slider-fg {\n background: #2fa1d6; }\n .dg .c .slider:hover {\n background: #3c3c3c; }\n .dg .c .slider:hover .slider-fg {\n background: #44abda; }\n", +dat.controllers.factory=function(f,a,d,e,c,b,p){return function(q,l,r,n){var u=q[l];if(p.isArray(r)||p.isObject(r))return new f(q,l,r);if(p.isNumber(u))return p.isNumber(r)&&p.isNumber(n)?new d(q,l,r,n):new a(q,l,{min:r,max:n});if(p.isString(u))return new e(q,l);if(p.isFunction(u))return new c(q,l,"");if(p.isBoolean(u))return new b(q,l)}}(dat.controllers.OptionController,dat.controllers.NumberControllerBox,dat.controllers.NumberControllerSlider,dat.controllers.StringController=function(f,a,d){var e= +function(c,b){function d(){f.setValue(f.__input.value)}e.superclass.call(this,c,b);var f=this;this.__input=document.createElement("input");this.__input.setAttribute("type","text");a.bind(this.__input,"keyup",d);a.bind(this.__input,"change",d);a.bind(this.__input,"blur",function(){f.__onFinishChange&&f.__onFinishChange.call(f,f.getValue())});a.bind(this.__input,"keydown",function(a){13===a.keyCode&&this.blur()});this.updateDisplay();this.domElement.appendChild(this.__input)};e.superclass=f;d.extend(e.prototype, +f.prototype,{updateDisplay:function(){a.isActive(this.__input)||(this.__input.value=this.getValue());return e.superclass.prototype.updateDisplay.call(this)}});return e}(dat.controllers.Controller,dat.dom.dom,dat.utils.common),dat.controllers.FunctionController,dat.controllers.BooleanController,dat.utils.common),dat.controllers.Controller,dat.controllers.BooleanController,dat.controllers.FunctionController,dat.controllers.NumberControllerBox,dat.controllers.NumberControllerSlider,dat.controllers.OptionController, +dat.controllers.ColorController=function(f,a,d,e,c){function b(a,b,d,e){a.style.background="";c.each(l,function(c){a.style.cssText+="background: "+c+"linear-gradient("+b+", "+d+" 0%, "+e+" 100%); "})}function p(a){a.style.background="";a.style.cssText+="background: -moz-linear-gradient(top, #ff0000 0%, #ff00ff 17%, #0000ff 34%, #00ffff 50%, #00ff00 67%, #ffff00 84%, #ff0000 100%);";a.style.cssText+="background: -webkit-linear-gradient(top, #ff0000 0%,#ff00ff 17%,#0000ff 34%,#00ffff 50%,#00ff00 67%,#ffff00 84%,#ff0000 100%);"; +a.style.cssText+="background: -o-linear-gradient(top, #ff0000 0%,#ff00ff 17%,#0000ff 34%,#00ffff 50%,#00ff00 67%,#ffff00 84%,#ff0000 100%);";a.style.cssText+="background: -ms-linear-gradient(top, #ff0000 0%,#ff00ff 17%,#0000ff 34%,#00ffff 50%,#00ff00 67%,#ffff00 84%,#ff0000 100%);";a.style.cssText+="background: linear-gradient(top, #ff0000 0%,#ff00ff 17%,#0000ff 34%,#00ffff 50%,#00ff00 67%,#ffff00 84%,#ff0000 100%);"}var q=function(f,n){function u(b){v(b);a.bind(window,"mousemove",v);a.bind(window, +"mouseup",l)}function l(){a.unbind(window,"mousemove",v);a.unbind(window,"mouseup",l)}function g(){var a=e(this.value);!1!==a?(t.__color.__state=a,t.setValue(t.__color.toOriginal())):this.value=t.__color.toString()}function k(){a.unbind(window,"mousemove",w);a.unbind(window,"mouseup",k)}function v(b){b.preventDefault();var c=a.getWidth(t.__saturation_field),d=a.getOffset(t.__saturation_field),e=(b.clientX-d.left+document.body.scrollLeft)/c;b=1-(b.clientY-d.top+document.body.scrollTop)/c;1 +b&&(b=0);1e&&(e=0);t.__color.v=b;t.__color.s=e;t.setValue(t.__color.toOriginal());return!1}function w(b){b.preventDefault();var c=a.getHeight(t.__hue_field),d=a.getOffset(t.__hue_field);b=1-(b.clientY-d.top+document.body.scrollTop)/c;1b&&(b=0);t.__color.h=360*b;t.setValue(t.__color.toOriginal());return!1}q.superclass.call(this,f,n);this.__color=new d(this.getValue());this.__temp=new d(0);var t=this;this.domElement=document.createElement("div");a.makeSelectable(this.domElement,!1); +this.__selector=document.createElement("div");this.__selector.className="selector";this.__saturation_field=document.createElement("div");this.__saturation_field.className="saturation-field";this.__field_knob=document.createElement("div");this.__field_knob.className="field-knob";this.__field_knob_border="2px solid ";this.__hue_knob=document.createElement("div");this.__hue_knob.className="hue-knob";this.__hue_field=document.createElement("div");this.__hue_field.className="hue-field";this.__input=document.createElement("input"); +this.__input.type="text";this.__input_textShadow="0 1px 1px ";a.bind(this.__input,"keydown",function(a){13===a.keyCode&&g.call(this)});a.bind(this.__input,"blur",g);a.bind(this.__selector,"mousedown",function(b){a.addClass(this,"drag").bind(window,"mouseup",function(b){a.removeClass(t.__selector,"drag")})});var y=document.createElement("div");c.extend(this.__selector.style,{width:"122px",height:"102px",padding:"3px",backgroundColor:"#222",boxShadow:"0px 1px 3px rgba(0,0,0,0.3)"});c.extend(this.__field_knob.style, +{position:"absolute",width:"12px",height:"12px",border:this.__field_knob_border+(.5>this.__color.v?"#fff":"#000"),boxShadow:"0px 1px 3px rgba(0,0,0,0.5)",borderRadius:"12px",zIndex:1});c.extend(this.__hue_knob.style,{position:"absolute",width:"15px",height:"2px",borderRight:"4px solid #fff",zIndex:1});c.extend(this.__saturation_field.style,{width:"100px",height:"100px",border:"1px solid #555",marginRight:"3px",display:"inline-block",cursor:"pointer"});c.extend(y.style,{width:"100%",height:"100%", +background:"none"});b(y,"top","rgba(0,0,0,0)","#000");c.extend(this.__hue_field.style,{width:"15px",height:"100px",display:"inline-block",border:"1px solid #555",cursor:"ns-resize"});p(this.__hue_field);c.extend(this.__input.style,{outline:"none",textAlign:"center",color:"#fff",border:0,fontWeight:"bold",textShadow:this.__input_textShadow+"rgba(0,0,0,0.7)"});a.bind(this.__saturation_field,"mousedown",u);a.bind(this.__field_knob,"mousedown",u);a.bind(this.__hue_field,"mousedown",function(b){w(b);a.bind(window, +"mousemove",w);a.bind(window,"mouseup",k)});this.__saturation_field.appendChild(y);this.__selector.appendChild(this.__field_knob);this.__selector.appendChild(this.__saturation_field);this.__selector.appendChild(this.__hue_field);this.__hue_field.appendChild(this.__hue_knob);this.domElement.appendChild(this.__input);this.domElement.appendChild(this.__selector);this.updateDisplay()};q.superclass=f;c.extend(q.prototype,f.prototype,{updateDisplay:function(){var a=e(this.getValue());if(!1!==a){var f=!1; +c.each(d.COMPONENTS,function(b){if(!c.isUndefined(a[b])&&!c.isUndefined(this.__color.__state[b])&&a[b]!==this.__color.__state[b])return f=!0,{}},this);f&&c.extend(this.__color.__state,a)}c.extend(this.__temp.__state,this.__color.__state);this.__temp.a=1;var l=.5>this.__color.v||.5a&&(a+=1);return{h:360*a,s:c/b,v:b/255}},rgb_to_hex:function(a,d,e){a=this.hex_with_component(0,2,a);a=this.hex_with_component(a,1,d);return a=this.hex_with_component(a,0,e)},component_from_hex:function(a,d){return a>>8*d&255},hex_with_component:function(a,d,e){return e<<(f=8*d)|a&~(255< Math.abs(U[max*n+i])) + max = row; + if (max > 0) + U.swap_rows(i, max); + if (U[i*n+i] == 0) return NaN; + for (var row = i + 1; row < n; ++row) { + var r = U[row*n+i] / U[i*n+i]; + if (r == 0) continue; + for (var col = i; col < n; ++col); + U[row*n+col] -= U[i*n+col] * r; + } + } + var det = 1; + for (var i = 0; i < n; ++i) + det *= U[i*n+i]; + return det; +}; + +/** + * Generalized dot product (sum of element-wise multiplication) + * @param {Float64Array} other another "matrix" of same size + * @return {float} + */ +Float64Array.prototype.dot = function(other) { + var prod = 0; + for (var i = 0; i < this.length; ++i) + prod += this[i] * other[i]; + return prod; +}; + +/** + * Matrix multiplication (naive implementation) + * @param {Float64Array} other + * @return {Float64Array} + */ +Float64Array.prototype.multiply = function(other) { + var A = this, B = other; + if (A.cols != B.rows) throw 'multiply() dimension mismatch'; + var n = A.rows, l = A.cols, m = B.cols; + var C = Float64Array.zeros(n, m) + // vector-vector product + if (m == 1 && n == 1) { + C[0] = A.dot(B); + return C; + } + // matrix-vector product + if (m == 1) { + for (var i = 0; i < n; ++i) + for (var j = 0; j < l; ++j) + C[i] += A[i*l+j] * B[j]; + return C; + } + // vector-matrix product + if (n == 1) { + for (var j = 0; j < m; ++j) { + for (var k = 0; k < l; ++k) + C[j] += A[k] * B[k * m + j]; + } + return C; + } + // matrix-matrix product + for (var i = 0; i < n; ++i) { + for (var j = 0; j < m; ++j) { + var cij = 0; + for (var k = 0; k < l; ++k) + cij += A[i * l + k] * B[k * m + j]; + C[i * m + j] = cij; + } + } + return C; +}; + +/** + * Computes PA = LU decomposition + * @return {object} {L, U, P} + */ +Float64Array.prototype.lu = function() { + if (this.rows != this.cols) throw 'lu() requires square matrix'; + var n = this.rows; + var L = Float64Array.zeros(n, n); + var U = Float64Array.zeros(n, n); + var P = Float64Array.eye(n, n); + for (var j = 0; j < n; ++j) { + var max = j; + for (var i = j; i < n; ++i) + if (Math.abs(this[i*n+j]) > Math.abs(this[max*n+j])) + max = i; + if (j != max) + P.swap_rows(j, max); + } + var PA = P.multiply(this); + for (var j = 0; j < n; ++j) { + L[j*n+j] = 1; + for (var i = 0; i < j+1; ++i) { + var s = 0; + for (var k = 0; k < i; ++k) + s += U[k*n+j] * L[i*n+k] + U[i*n+j] = PA[i*n+j] - s + } + for (var i = j; i < n; ++i) { + var s = 0; + for (var k = 0; k < i; ++k) + s += U[k*n+j] * L[i*n+k] + L[i*n+j] = (PA[i*n+j] - s) / U[j*n+j]; + } + } + return {L:L, U:U, P:P}; +}; + +/** + * Cholesky A = LL^T decomposition (in-place) + * @return {[type]} [description] + */ +Float64Array.prototype.chol_inplace = function() { + if (this.rows != this.cols) throw 'chol_inplace() requires square matrix'; + var A = this; + var m = A.rows, n = A.cols; + var i, j, k, s = 0.0; + for (i = 0; i < n; ++i) { + for (j = 0; j < (i + 1); ++j) { + s = 0.0; + for (k = 0; k < j; ++k) + s += A[i * n + k] * A[j * n + k]; + if (i != j) A[j * n + i] = 0; + if (i == j && A[i * n + i] - s < 0) throw "chol_inplace() matrix not positive definite"; + A[i * n + j] = (i == j) ? Math.sqrt(A[i * n + i] - s) : ((A[i * n + j] - s) / A[j * n + j]); + } + } + return A; +}; + +/** + * Cholesky A = LL^T decomposition (returns copy) + * @return {Float64Array} + */ +Float64Array.prototype.chol = function() { + return this.copy().chol_inplace(); +}; + +/** + * Solves Lx = b using foward substitution, updates b + * @param {Float64Array} b rhs + * @return {Float64Array} + */ +Float64Array.prototype.fsolve_inplace = function(b) { + var L = this; + var m = L.rows, n = L.cols; + for (var i = 0; i < n; ++i) { + var s = 0.0 + for (var j = 0; j < i; ++j) + s += L[i * n + j] * b[j]; + b[i] = (b[i] - s) / L[i * n + i]; + } + return b; +}; + +/** + * Solves Lx = b using foward substitution + * @param {Float64Array} b rhs + * @return {Float64Array} + */ +Float64Array.prototype.fsolve = function(b) { + return this.fsolve_inplace(b.copy()); +}; + +/** + * Solves Ux = b using backward substitution, updates b + * @param {Float64Array} b rhs + * @param {object} options {transpose: false} + * @return {Float64Array} + */ +Float64Array.prototype.bsolve_inplace = function(b, options) { + var U = this; + var m = U.rows, n = U.cols; + options = options || {}; + var transpose = options.hasOwnProperty('transpose') ? options.transpose : false; + for (var i = n - 1; i >= 0; --i) { + var s = 0.0; + for (var j = i + 1; j < n; ++j) + s += (transpose ? U[j * n + i] : U[i * n + j]) * b[j]; + b[i] = (b[i] - s) / U[i * n + i]; + } + return b; +}; + +/** + * Solves Ux = b using backward substitution + * @param {Float64Array} b rhs + * @param {object} options {transpose: false} + * @return {Float64Array} + */ +Float64Array.prototype.bsolve = function(b, options) { + return this.bsolve_inplace(b.copy(), options); +}; + +/** + * Solve Ax = b using PA = LU decomposition + * @param {Float64Array} b rhs + * @return {Float64Array} x + */ +Float64Array.prototype.lu_solve = function(b) { + var res = this.lu(), P = res.P, L = res.L, U = res.U; + return U.bsolve(L.fsolve(P.multiply(b))); +}; + +/** + * Computes the matrix inverse using PA = LU decomposition + * @return {Float64Array} A^-1 + */ +Float64Array.prototype.lu_inverse = function() { + var res = this.lu(), P = res.P, L = res.L, U = res.U; + var inverse = Float64Array.zeros(this.rows, this.cols); + var eye = Float64Array.eye(this.rows, this.cols); + for (var j = 0; j < this.cols; ++j) { + inverse.setCol(j, U.bsolve(L.fsolve(P.multiply(eye.col(j))))); + } + return inverse; +}; + +/** + * Solve Ax = b using A = LL^T decomposition + * @param {Float64Array} b rhs + * @return {Float64Array} x + */ +Float64Array.prototype.llt_solve = function(b) { + var L = this.chol(); + return L.bsolve(L.fsolve(b), {transpose: true}); +}; + +/** + * Computes the matrix inverse using LL^T decomposition + * @return {Float64Array} A^-1 + */ +Float64Array.prototype.llt_inverse = function() { + var L = this.chol(); + var inverse = Float64Array.zeros(this.rows, this.cols); + var eye = Float64Array.eye(this.rows, this.cols); + for (var j = 0; j < this.cols; ++j) { + inverse.setCol(j, L.bsolve(L.fsolve(eye.col(j)), {transpose: true})); + } + return inverse; +}; + +/** + * Solve Ax = b using A = LL^T decomposition (in-place) + * @param {Float64Array} b rhs + * @return {Float64Array} x + */ +Float64Array.prototype.llt_solve_inplace = function(b) { + var L = this.chol_inplace(); + return L.bsolve_inplace(L.fsolve_inplace(b), {transpose: true}); +}; + + +/** + * Computes diagonal matrix D of eigenvalues and matrix V whose columns are the corresponding + * right eigenvectors so that AV = VD + * @param {object} options tolerance and maxIter + * @return {object} V:V D:D + */ +Float64Array.prototype.jacobiRotation = function(options) { + + if (this.cols != this.rows) throw 'matrix must be square'; + + if (arguments.length < 1) + options = {}; + + var maxIter = options.maxIter || 100; + var tolerance = options.tolerance || 1e-5; + + var n = this.rows; + var D = this.copy(); + var V = Float64Array.eye(n, n); + + var iter, maxOffDiag, p, q; + for (iter = 0; iter < maxIter; ++iter) { + + // find max off diagonal term at (p, q) + maxOffDiag = 0; + for (var i = 0; i < n - 1; ++i) { + for (var j = i + 1; j < n; ++j) { + if (Math.abs(D[i * n + j]) > maxOffDiag) { + maxOffDiag = Math.abs(D[i * n + j]); + p = i; q = j; + } + } + } + + if (maxOffDiag < tolerance) + break; + + // Rotates matrix D through theta in pq-plane to set D[p][q] = 0 + // Rotation stored in matrix V whose columns are eigenvectors of D + // d = cot 2 * theta, t = tan theta, c = cos theta, s = sin theta + var d = (D[p * n + p] - D[q * n + q]) / (2.0 * D[p * n + q]); + var t = Math.sign(d) / (Math.abs(d) + Math.sqrt(d * d + 1)); + var c = 1.0 / Math.sqrt(t * t + 1); + var s = t * c; + D[p * n + p] += t * D[p * n + q]; + D[q * n + q] -= t * D[p * n + q]; + D[p * n + q] = D[q * n + p] = 0.0; + for (var k = 0; k < n; k++) { // Transform D + if (k != p && k != q) { + var akp = c * D[k * n + p] + s * D[k * n + q]; + var akq = -s * D[k * n + p] + c * D[k * n + q]; + D[k * n + p] = akp; + D[p * n + k] = akp; + D[k * n + q] = akq; + D[q * n + k] = akq; + } + } + for (var k = 0; k < n; k++) { // Store V + var rkp = c * V[k * n + p] + s * V[k * n + q]; + var rkq = -s * V[k * n + p] + c * V[k * n + q]; + V[k * n + p] = rkp; + V[k * n + q] = rkq; + } + } + + if (iter == maxIter) { + console.log('Hit maxIter: ', maxOffDiag, ' > ', tolerance); + } + + return {V:V, D:D, eigenvalues: D.diagonal(), eigenvectors: V}; + +}; + +Float64Array.prototype.maxCoeff = function() { + var max = this[0]; + for (var i = 0; i < this.length; ++i) { + if (this[i] > max) + max = this[i]; + } + return max; +}; + +Float64Array.prototype.cwiseProduct = function(other) { + var A = this.copy(); + for (var i = 0; i < this.length; ++i) { + A[i] = this[i] * other[i]; + } + return A; +}; + +Float64Array.prototype.cwiseQuotient = function(other) { + var A = this.copy(); + for (var i = 0; i < this.length; ++i) { + A[i] = this[i] / other[i]; + } + return A; +}; + +Float64Array.prototype.cwiseInverse = function() { + var A = this.copy(); + for (var i = 0; i < this.length; ++i) { + A[i] = 1.0 / this[i]; + } + return A; +}; + +Float64Array.prototype.cwiseSqrt = function() { + var A = this.copy(); + for (var i = 0; i < this.length; ++i) { + A[i] = Math.sqrt(this[i]); + } + return A; +}; + +// get sub-block of matrix +Float64Array.prototype.getBlock = function(top, left, rows, cols) { + var B = new Float64Array(rows*cols); + B.rows = rows; + B.cols = cols; + for (var i = 0; i < rows; i++) { + for (var j = 0; j < cols; j++) { + B[i*B.cols+j] = this[(i+top)*this.cols + (j+left)]; + } + } + return B; +} + +// set sub-block of matrix to another matrix +Float64Array.prototype.setBlock = function(top, left, A) { + for (var i = 0; i < A.rows; i++) { + for (var j = 0; j < A.cols; j++) { + this[(i+top)*this.cols + (j+left)] = A[i*A.cols+j]; + } + } +}; + +// QR decomposition using Householder reflections. +Float64Array.prototype.qr = function() { + var make_householder = function(a) { + var v = a.scale(1 / (a[0] + Math.sign(a[0]) * a.norm())); + v[0] = 1; + var H = Float64Array.eye(a.length); + H.decrement(Float64Array.outer(v, v).scale(2 / v.dot(v))); + return H; + }; + var A = this.copy(); + var m = A.rows; + var n = A.cols; + var Q = Float64Array.eye(m); + var upper = n - ((m == n) ? 1 : 0); + for (var i = 0; i < upper; i++) { + var a = A.getBlock(i, i, m - i, 1); + var H = Float64Array.eye(m); + H.setBlock(i, i, make_householder(a)); + Q = Q.multiply(H); + A = H.multiply(A); + } + return {Q:Q, R:A}; +}; + +var zeros = Float64Array.zeros; +var eye = Float64Array.eye; +var linspace = Float64Array.linspace; +var matrix = Float64Array.matrix; + diff --git a/docs/static/mcmc-demo/lib/linalg.opt.js b/docs/static/mcmc-demo/lib/linalg.opt.js new file mode 100644 index 00000000..c1a00089 --- /dev/null +++ b/docs/static/mcmc-demo/lib/linalg.opt.js @@ -0,0 +1,99 @@ +/* +Copyright (c) 2016 Chi Feng + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ + +"use strict"; + +/** + * Unconstrained gradient-based optimization + * @param {function} f objective function + * @param {function} g gradient of objective function + * @param {object} user_opts + */ +Float64Array.opt = function(f, g, user_opts) { + + // defaults + var options = { + method: 'bfgs', + tolerance: 1e-6, + step_size: 1.0, + max_iter: 100, + warn_max_iter: false, + line_search_iter: 20, + line_search_tolerance: 1e-4 + }; + + // assign dim + if (!user_opts.hasOwnProperty('x0')) + throw 'x0 not set'; + else + options.dim = user_opts.x0.length; + + // assign user options + for (var key in user_opts) { + options[key] = user_opts[key]; + } + + // run optimization + if (options.method == 'bfgs') { + return Float64Array.bfgs(f, g, options); + } else { + throw 'unrecognized method'; + } + +}; + +Float64Array.bfgs = function(f, grad, options) { + var n = options.dim; + var geval = 0; + // bisection line search in the direction p + var line_search = function(x, p) { + var a, a_lo = 0, a_hi = options.step_size; + for (var k = 0; k < options.line_search_iter; k++) { + a = (a_hi + a_lo) / 2; + var h = grad(x.add(p.scale(a))).dot(p); geval++; + if (h > options.line_search_tolerance) a_hi = a; + else if (h < -options.line_search_tolerance) a_lo = a; + else break; + } + return a; + }; + // BFGS + var B = Float64Array.eye(options.dim, options.dim); + var x = [options.x0.copy()]; + var p = [ ], a = [ ], g = [grad(x[0])]; geval++; + var k = 0; + for (k = 0; k < options.max_iter; k++) { + if (g[k].dot(g[k]) < options.tolerance) break; + p.push(B.lu_solve(g[k].negate())); + a.push(line_search(x[k], p[k])); + x.push(x[k].add(p[k].scale(a[k]))); + g.push(grad(x[k+1])); geval++; + var s = p[k].scale(a[k]); + var y = g[k+1].subtract(g[k]); + var first = Float64Array.outer(y, y).scale(1.0 / y.dot(s)); + var second = B.multiply(Float64Array.outer(s, s.transpose().multiply(B))).scale(1.0 / s.dot(B.multiply(s))); + B.increment(first.subtract(second)); + } + if (k == options.max_iter && options.warn_max_iter) + console.log('max_iter exceeded, gradient is', g[k]); + return {x: x[x.length-1], trajectory: x, p: p, a:a, geval: geval}; +}; diff --git a/docs/using-ultranest.ipynb b/docs/using-ultranest.ipynb index c9c1f242..a09159e0 100644 --- a/docs/using-ultranest.ipynb +++ b/docs/using-ultranest.ipynb @@ -212,7 +212,7 @@ "\n", "\n", "\n", - "Both [ReactiveNestedSampler](modules.html#ultranest.integrator.ReactiveNestedSampler) and [its .run() function](modules.html#ultranest.integrator.ReactiveNestedSampler.run) have several options to specify what logging and file output they should produce, and how they should explore the parameter space.\n", + "Both [ReactiveNestedSampler](ultranest.html#ultranest.integrator.ReactiveNestedSampler) and [its .run() function](ultranest.html#ultranest.integrator.ReactiveNestedSampler.run) have several options to specify what logging and file output they should produce, and how they should explore the parameter space.\n", "\n" ] }, @@ -249,7 +249,7 @@ ], "metadata": { "kernelspec": { - "display_name": "Python 3", + "display_name": "Python 3 (ipykernel)", "language": "python", "name": "python3" }, @@ -263,7 +263,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.8.5" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/evaluate/evaluate_sampling.py b/evaluate/evaluate_sampling.py index 5e8562db..a193901c 100644 --- a/evaluate/evaluate_sampling.py +++ b/evaluate/evaluate_sampling.py @@ -1,44 +1,44 @@ import numpy as np import matplotlib.pyplot as plt -from ultranest.mlfriends import ScalingLayer, AffineLayer, MLFriends +from ultranest.mlfriends import ScalingLayer, AffineLayer, RobustEllipsoidRegion from ultranest.stepsampler import RegionMHSampler, CubeMHSampler from ultranest.stepsampler import CubeSliceSampler, RegionSliceSampler, RegionBallSliceSampler, RegionSequentialSliceSampler, SpeedVariableRegionSliceSampler -from ultranest.stepsampler import AHARMSampler +#from ultranest.stepsampler import AHARMSampler #from ultranest.stepsampler import OtherSamplerProxy, SamplingPathSliceSampler, SamplingPathStepSampler #from ultranest.stepsampler import GeodesicSliceSampler, RegionGeodesicSliceSampler import tqdm import joblib -import warnings +import warnings, traceback from problems import transform, get_problem -#mem = joblib.Memory('.', verbose=False) +mem = joblib.Memory('.', verbose=False) def quantify_step(a, b): # euclidean step distance - stepsize = ((a - b)**2).sum() + stepsize = np.linalg.norm(a - b) # assuming a center = 0.5 da = a - center db = b - center - ra = ((da**2).sum())**0.5 - rb = ((db**2).sum())**0.5 + ra = np.linalg.norm(da) + rb = np.linalg.norm(db) # compute angle between vectors da, db angular_step = np.arccos(np.dot(da, db) / (ra * rb)) # compute step in radial direction radial_step = np.abs(ra - rb) return [stepsize, angular_step, radial_step] -#@mem.cache -def evaluate_warmed_sampler(problemname, ndim, nlive, nsteps, sampler): +@mem.cache +def evaluate_warmed_sampler(problemname, ndim, nlive, nsteps, sampler, seed=1, region_class=RobustEllipsoidRegion): loglike, grad, volume, warmup = get_problem(problemname, ndim=ndim) if hasattr(sampler, 'set_gradient'): sampler.set_gradient(grad) - np.random.seed(1) + np.random.seed(seed) def multi_loglike(xs): return np.asarray([loglike(x) for x in xs]) us = np.array([warmup(ndim) for i in range(nlive)]) Ls = np.array([loglike(u) for u in us]) - vol0 = max((volume(Li, ndim) for Li in Ls)) + vol0 = volume(Ls.min(), ndim) nwarmup = 3 * nlive if ndim > 1: @@ -46,7 +46,7 @@ def multi_loglike(xs): else: transformLayer = ScalingLayer() transformLayer.optimize(us, us) - region = MLFriends(us, transformLayer) + region = region_class(us, transformLayer) region.maxradiussq, region.enlarge = region.compute_enlargement(nbootstraps=30) region.create_ellipsoid(minvol=vol0) assert region.ellipsoid_center is not None @@ -61,9 +61,9 @@ def multi_loglike(xs): with warnings.catch_warnings(), np.errstate(all='raise'): try: nextTransformLayer = transformLayer.create_new(us, region.maxradiussq, minvol=minvol) - nextregion = MLFriends(us, nextTransformLayer) + nextregion = region_class(us, nextTransformLayer) nextregion.maxradiussq, nextregion.enlarge = nextregion.compute_enlargement(nbootstraps=30) - if nextregion.estimate_volume() <= region.estimate_volume(): + if isinstance(nextregion, RobustEllipsoidRegion) or nextregion.estimate_volume() <= region.estimate_volume(): nextregion.create_ellipsoid(minvol=minvol) region = nextregion transformLayer = region.transformLayer @@ -106,7 +106,7 @@ def __init__(self): self.adaptive_nsteps = False def __next__(self, region, Lmin, us, Ls, transform, loglike): - u, father = region.sample(nsamples=self.ndraw) + u = region.sample(nsamples=self.ndraw) nu = u.shape[0] self.starti = np.random.randint(len(us)) if nu > 0: @@ -136,7 +136,7 @@ def main(args): #CubeMHSampler(nsteps=16), #CubeMHSampler(nsteps=4), CubeMHSampler(nsteps=1), #RegionMHSampler(nsteps=16), #RegionMHSampler(nsteps=4), RegionMHSampler(nsteps=1), ##DESampler(nsteps=16), DESampler(nsteps=4), #DESampler(nsteps=1), - #CubeSliceSampler(nsteps=2*ndim), CubeSliceSampler(nsteps=ndim), CubeSliceSampler(nsteps=max(1, ndim//2)), + CubeSliceSampler(nsteps=2*ndim), #CubeSliceSampler(nsteps=ndim), CubeSliceSampler(nsteps=max(1, ndim//2)), #RegionSliceSampler(nsteps=ndim), RegionSliceSampler(nsteps=max(1, ndim//2)), #RegionSliceSampler(nsteps=2), RegionSliceSampler(nsteps=4), #RegionSliceSampler(nsteps=ndim), RegionSliceSampler(nsteps=4*ndim), @@ -145,11 +145,6 @@ def main(args): #SpeedVariableRegionSliceSampler([Ellipsis]*ndim), SpeedVariableRegionSliceSampler([slice(i, ndim) for i in range(ndim)]), #SpeedVariableRegionSliceSampler([Ellipsis]*ndim + [slice(1 + ndim//2, None)]*ndim), - - AHARMSampler(nsteps=64), - AHARMSampler(nsteps=64, adaptive_nsteps='move-distance'), - AHARMSampler(nsteps=64, region_filter=False), - AHARMSampler(nsteps=64, orthogonalise=True), ] if ndim < 14: samplers.insert(0, MLFriendsSampler()) @@ -208,15 +203,15 @@ def main(args): axspeed.plot([lastspeed[1], cdf_expected.mean()], [lastspeed[2], ncalls], '-', color=color) lastspeed = [samplername, cdf_expected.mean(), ncalls] - stepsizesq, angular_step, radial_step = steps.transpose() - assert len(stepsizesq) == len(Lsequence), (len(stepsizesq), len(Lsequence)) + stepsize, angular_step, radial_step = steps.transpose() + assert len(stepsize) == len(Lsequence), (len(stepsize), len(Lsequence)) # here we estimate the volume differently: from the expected shrinkage per iteration it = np.arange(len(stepsizesq)) vol = (1 - 1. / nlive)**it assert np.isfinite(vol).all(), vol assert (vol > 0).all(), vol assert (vol <= 1).all(), vol - relstepsize = stepsizesq**0.5 / vol**(1. / ndim) + relstepsize = stepsize / vol**(1. / ndim) relradial_step = radial_step / vol**(1. / ndim) axstep1.hist(relstepsize[np.isfinite(relstepsize)], bins=1000, cumulative=True, density=True, histtype='step', diff --git a/evaluate/problems.py b/evaluate/problems.py index 394f556f..c2868874 100644 --- a/evaluate/problems.py +++ b/evaluate/problems.py @@ -57,7 +57,7 @@ def volume_asymgauss(loglike, ndim): return np.nan # compute volume of a n-sphere - return nsphere_volume(radius, ndim) * np.product(asym_sigma / asym_sigma_max) + return nsphere_volume(radius, ndim) * np.prod(asym_sigma / asym_sigma_max) gradient_asymgauss = gradient_to_center @@ -67,6 +67,43 @@ def warmup_asymgauss(ndim): return loglike_asymgauss, gradient_asymgauss, volume_asymgauss, warmup_asymgauss +def generate_corrgauss_problem(ndim, gamma=0.95): + mean = np.zeros(ndim) + M = np.ones((ndim, ndim)) * gamma + np.fill_diagonal(M, 1) + Minv = np.linalg.inv(M) + Mdet = np.linalg.det(M) + center = np.zeros(ndim) + + loglike_asymgauss, gradient_asymgauss, volume_asymgauss, warmup_asymgauss = generate_asymgauss_problem(ndim) + + from ultranest.mlfriends import AffineLayer + + layer = AffineLayer(center, M, Minv) + + def warmup_corrgauss(ndim): + # the gaussian is defined in our aux coordinate system: + y = warmup_asymgauss(ndim) + # so transform to these + return layer.transform(y - 0.5) + 0.5 + + def loglike_corrgauss(x): + """ gaussian problem """ + # transform back to aux coordinate system, where gaussian is nice + y = layer.untransform(x - 0.5) + 0.5 + return loglike_asymgauss(y) + + def volume_corrgauss(loglike, ndim): + # volume is defined in aux coordinate system + # we hope that no intersection with unit cube happens + return volume_asymgauss(loglike, ndim) / Mdet + + def gradient_corrgauss(x): + y = layer.untransform(x - 0.5) + 0.5 + return gradient_to_center(y) + + return loglike_corrgauss, gradient_corrgauss, volume_corrgauss, warmup_corrgauss + def loglike_pyramid(x): """ hyper-pyramid problem (squares) """ @@ -187,6 +224,8 @@ def get_problem(problemname, ndim): return loglike_gauss, gradient_gauss, volume_gauss, warmup_gauss elif problemname == 'asymgauss': return generate_asymgauss_problem(ndim) + elif problemname == 'corrgauss': + return generate_corrgauss_problem(ndim) elif problemname == 'pyramid': return loglike_pyramid, gradient_pyramid, volume_pyramid, warmup_pyramid elif problemname == 'multigauss': @@ -195,6 +234,3 @@ def get_problem(problemname, ndim): return loglike_shell, gradient_shell, volume_shell, warmup_shell raise Exception("Problem '%s' unknown" % problemname) - - - diff --git a/examples/runfeatures.sh b/examples/runfeatures.sh new file mode 100644 index 00000000..65673b82 --- /dev/null +++ b/examples/runfeatures.sh @@ -0,0 +1,12 @@ +python3 examples/testfeatures.py testfeatures/runsettings-7e3ca1f36c-iterated.json +python3 examples/testfeatures.py --random --seed=25 +python3 examples/testfeatures.py --random --seed=54 +python3 examples/testfeatures.py --random --seed=63 +python3 examples/testfeatures.py --random --seed=67 +python3 examples/testfeatures.py --random --seed=68 +python3 examples/testfeatures.py --random --seed=69 +python3 examples/testfeatures.py --random --seed=81 +python3 examples/testfeatures.py --random --seed=82 +python3 examples/testfeatures.py --random --seed=90 +python3 examples/testfeatures.py --random --seed=94 +python3 examples/testfeatures.py --random --seed=99 diff --git a/examples/test_PopSliceSampler.py b/examples/test_PopSliceSampler.py new file mode 100644 index 00000000..3564fc1b --- /dev/null +++ b/examples/test_PopSliceSampler.py @@ -0,0 +1,144 @@ +import argparse +import numpy as np + +def main(args): + + ndim = args.x_dim + paramnames = ['param%d' % (i+1) for i in range(ndim)] + if args.seed is not None: + np.random.seed(args.seed) + if args.problem == 'rosenbrock': + def loglike(theta): + a = theta[:,:-1] + b = theta[:,1:] + return -2 * (100 * (b - a**2)**2 + (1 - a)**2).sum(axis=1) + + def transform(u): + return u * 20 - 10 + if args.problem == 'multishell': + from numpy import exp, log, pi + import scipy + def shell_vol(ndim, r, w): + # integral along the radius + mom = scipy.stats.norm.moment(ndim - 1, loc=r, scale=w) + # integral along the angles is surface of hyper-ball + # which is volume of one higher dimension x (ndim + 1) + vol = pi**((ndim)/2.) / scipy.special.gamma((ndim)/2. + 1) + surf = vol * ndim + return mom * surf + + r = 0.2 + # the shell thickness is + #w = (r**(ndim+1) + C * scipy.special.gamma((ndim+3)/2)*ndim*pi**(-(ndim+1)/2) / ( + # scipy.special.gamma((ndim+2)/2) * pi**(-ndim/2)))**(1 / (ndim+1)) - r + w = 0.001 / ndim + + r1, r2 = r, r + w1, w2 = w, w + c1, c2 = np.zeros(ndim) + 0.5, np.zeros(ndim) + 0.5 + c1[0] -= r1 / 2 + c2[0] += r2 / 2 + N1 = -0.5 * log(2 * pi * w1**2) + N2 = -0.5 * log(2 * pi * w2**2) + Z_analytic = log(shell_vol(ndim, r1, w1) + shell_vol(ndim, r2, w2)) + + def loglike(theta): + d1 = ((theta - c1)**2).sum(axis=1)**0.5 + d2 = ((theta - c2)**2).sum(axis=1)**0.5 + L1 = -0.5 * ((d1 - r1)**2) / w1**2 + N1 + L2 = -0.5 * ((d2 - r2)**2) / w2**2 + N2 + return np.logaddexp(L1, L2) + + def transform(x): + return x + + if args.problem == 'gaussian': + sigma = args.sigma + width = max(0, 1 - 5 * sigma) + centers = (np.sin(np.arange(ndim)/2.) * width + 1.) / 2. + sigma = np.random.uniform(0.01, 1., ndim)*sigma + centers=centers.reshape((1,ndim)) + sigma=np.array(sigma.reshape((1,ndim))) + + norm = -0.5 * np.log(2 * np.pi * sigma**2).sum() + def loglike(theta): + return -0.5 * (((theta - centers) / sigma)**2).sum(axis=1) + norm + + def transform(x): + return x + if args.problem == 'eggbox': + def loglike(theta): + return np.cos(theta).prod(axis=1)**2 + + def transform(x): + return x * 10 * np.pi + + if args.problem == 'funnel': + sigma = args.sigma + centers = np.sin(np.arange(ndim) / 2.) + data = np.random.normal(centers, sigma).reshape((1, -1)) + + def loglike(theta): + sigma = 10**theta[:,0] + + like = -0.5 * (((theta[:,1:] - data)/sigma.reshape((-1, 1)))**2).sum(axis=1) - 0.5 * np.log(2 * np.pi * sigma**2) * ndim + return like + + def transform(x): + z = x * 20 - 10 + z[:,0] = x[:,0] * 6 - 3 + return z + import string + #print(ndim, len(list(string.ascii_lowercase))) + paramnames = ['sigma'] + ['param%d' % (i+1) for i in range(ndim)][:ndim] + + + from ultranest import ReactiveNestedSampler + from ultranest.calibrator import ReactiveNestedCalibrator + + if args.run_type=='Calibration': + sampler = ReactiveNestedCalibrator(paramnames, loglike,\ + transform=transform, log_dir=args.log_dir, resume='overwrite',\ + draw_multiple=False, vectorized=True,) + if args.run_type=='Normal': + sampler = ReactiveNestedSampler(paramnames, loglike,\ + transform=transform, log_dir=args.log_dir, resume='overwrite',\ + draw_multiple=False, vectorized=True,) + if args.Sampler=='SimSlice': + import ultranest.popstepsampler as ultrapop + direction=[ultrapop.generate_cube_oriented_direction,ultrapop.generate_mixture_random_direction,ultrapop.generate_differential_direction,ultrapop.generate_region_random_direction,ultrapop.generate_region_oriented_direction,ultrapop.generate_random_direction] + sampler.stepsampler = ultrapop.PopulationSimpleSliceSampler(popsize=args.popsize,nsteps=args.nstep,generate_direction=direction[args.direction]) + if args.Sampler=='PopSlice': + import ultranest.popstepsampler as ultrapop + direction=[ultrapop.generate_cube_oriented_direction,ultrapop.generate_mixture_random_direction,ultrapop.generate_differential_direction,ultrapop.generate_region_random_direction,ultrapop.generate_region_oriented_direction,ultrapop.generate_random_direction] + sampler.stepsampler = ultrapop.PopulationSliceSampler(popsize=args.popsize,nsteps=args.nstep,generate_direction=direction[args.direction],scale=1.0) + if args.Sampler=='Slice': + import ultranest.stepsampler as stepsampler + sampler.stepsampler = stepsampler.SliceSampler(nsteps=args.nstep,generate_direction=stepsampler.generate_mixture_random_direction,) + if args.Sampler=='PopGaussWalk': + import ultranest.popstepsampler as ultrapop + direction=[ultrapop.generate_cube_oriented_direction,ultrapop.generate_random_direction, ultrapop.generate_region_oriented_direction, ultrapop.generate_region_random_direction] + sampler.stepsampler = ultrapop.PopulationRandomWalkSampler(popsize=args.popsize, nsteps=args.nstep, generate_direction=direction[args.direction],scale=1.0,) + + result=sampler.run(frac_remain=0.5, min_num_live_points=args.num_live_points, max_num_improvement_loops=3) + + if args.run_type=='Normal': + sampler.print_results() + if ndim <= 20: + sampler.plot() +if __name__ == '__main__': + parser = argparse.ArgumentParser() + + parser.add_argument('--x_dim', type=int, default=2, + help="Dimensionality") + parser.add_argument("--num_live_points", type=int, default=400) + parser.add_argument('--log_dir', type=str) + parser.add_argument('--seed', type=int, default=0) + parser.add_argument('--problem', type=str,required=True,choices=['rosenbrock', 'multishell', 'gaussian', 'eggbox', 'funnel']) + parser.add_argument('--sigma', type=float, default=1) + parser.add_argument('--run_type', type=str, default='Normal', choices=['Normal', 'Calibration']) + parser.add_argument('--Sampler',type=str,required=True,choices=['SimSlice','PopSlice','Slice','PopGaussWalk']) + parser.add_argument('--popsize', type=int) + parser.add_argument('--nstep', type=int) + parser.add_argument('--direction', type=int) + main(parser.parse_args()) diff --git a/examples/test_popsampler.py b/examples/test_popsampler.py new file mode 100644 index 00000000..495fda6e --- /dev/null +++ b/examples/test_popsampler.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python +# coding: utf-8 + +import numpy as np +from ultranest import ReactiveNestedSampler +from ultranest.mlfriends import RobustEllipsoidRegion, SimpleRegion, ScalingLayer +import ultranest.popstepsampler +import matplotlib.pyplot as plt +import sys +import argparse + +def main(generate_direction_method, ndim, nsteps, popsize, log_dir=None, verbose=False): + np.random.seed(1) + + logsigma = -5 + + sigma = np.logspace(-1, logsigma, ndim) + width = 1 - 5 * sigma + width[width < 1e-20] = 1e-20 + centers = (np.sin(np.arange(ndim)/2.) * width + 1.) / 2. + #sigma[:] = 0.01 + #centers[:] = 0.5 + + norm = -0.5 * np.log(2 * np.pi * sigma**2).sum() + def loglike(theta): + return -0.5 * (((theta - centers) / sigma)**2).sum(axis=1) + norm + + def transform(x): + return x + + paramnames = ['param%d' % (i+1) for i in range(ndim)] + + sampler = ReactiveNestedSampler( + paramnames, loglike, transform=transform, + vectorized=True, log_dir=log_dir, resume=True) + + # ellipsoidal: + region_class = RobustEllipsoidRegion + # ellipsoidal axis-aligned: + #sampler.transform_layer_class = ScalingLayer + #region_class = SimpleRegion + + sampler.stepsampler = ultranest.popstepsampler.PopulationRandomWalkSampler( + popsize=popsize, nsteps=nsteps, scale=1. / len(paramnames), + generate_direction=getattr(ultranest.popstepsampler, generate_direction_method), log=verbose, + #logfile=sys.stderr + ) + results = sampler.run( + frac_remain=0.01, update_interval_volume_fraction=0.01, + max_num_improvement_loops=0, min_num_live_points=400, + viz_callback=None, region_class=region_class + ) + sampler.print_results() + stats = results['posterior'] + plt.errorbar(x=np.arange(ndim), y=stats['mean'] - centers, yerr=stats['stdev'] / sigma, color='k') + plt.savefig('populationstepsampler_%d.pdf' % ndim) + plt.close() + #sampler.plot_trace() + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + + parser.add_argument('--x_dim', type=int, default=2, + help="Dimensionality") + parser.add_argument("--num_live_points", type=int, default=400) + parser.add_argument("--generate_direction_method", type=str, required=True) + parser.add_argument("--num_steps", type=int, required=True) + parser.add_argument("--popsize", type=int, required=True) + parser.add_argument('--log_dir', type=str) + parser.add_argument('--verbose', action='store_true') + + args = parser.parse_args() + main(args.generate_direction_method, args.x_dim, args.num_steps, args.popsize, args.log_dir, verbose=args.verbose) diff --git a/examples/testfeatures.py b/examples/testfeatures.py index 13c9e374..b4a278a0 100644 --- a/examples/testfeatures.py +++ b/examples/testfeatures.py @@ -12,7 +12,7 @@ def get_arg_hash(runargs): - return hashlib.md5(str(sorted(runargs.items())).encode()).hexdigest()[:10] + return hashlib.md5(str(runargs).encode()).hexdigest()[:10] def main(args): @@ -112,7 +112,7 @@ def loglike(theta): L2 = np.log(0.5 * rv2a.pdf(theta[:,1]) + 0.5 * rv2b.pdf(theta[:,1])) Lrest = np.sum([rv.logpdf(t) for rv, t in zip(rv_rest, theta[:,2:].transpose())], axis=0) like = L1 + L2 + Lrest - like = np.where(like < -300, -300 - ((np.asarray(theta) - 0.5)**2).sum(), like) + like = np.where(like < -1e100, -1e100 - ((np.asarray(theta) - 0.5)**2).sum(), like) assert like.shape == (len(theta),), (like.shape, theta.shape) return like @@ -120,6 +120,7 @@ def transform(x): return x from ultranest import ReactiveNestedSampler + from ultranest.mlfriends import MLFriends, RobustEllipsoidRegion, SimpleRegion, ScalingLayer sampler = ReactiveNestedSampler( paramnames, loglike, transform=transform if args.pass_transform else None, @@ -127,6 +128,11 @@ def transform(x): resume='resume' if args.resume else 'overwrite', wrapped_params=wrapped_params, ) + if hasattr(args, 'axis_aligned') and args.axis_aligned: + sampler.transform_layer_class = ScalingLayer + region_class = SimpleRegion + else: + region_class = RobustEllipsoidRegion if hasattr(args, 'ellipsoidal') and args.ellipsoidal else MLFriends print("MPI:", sampler.mpi_size, sampler.mpi_rank) for result in sampler.run_iter( update_interval_volume_fraction=args.update_interval_iter_fraction, @@ -138,6 +144,7 @@ def transform(x): cluster_num_live_points=args.cluster_num_live_points, min_num_live_points=args.num_live_points, max_ncalls=int(args.max_ncalls), + region_class=region_class, ): sampler.print_results() print( @@ -166,6 +173,7 @@ def transform(x): def run_safely(runargs): id = get_arg_hash(runargs) if os.path.exists('testfeatures/%s.done' % id): + print("not rerunning %s" % id) return print("Running %s with options:" % id, runargs) @@ -254,6 +262,8 @@ def choose(myargs): min_ess = choose([0, 4000]), max_iters = choose([None, 10000]), max_ncalls = choose([10000000., 10000., 100000.]), + axis_aligned = choose([False, True]), + ellipsoidal = choose([False, True]), ) if not progargs.random: key = i diff --git a/pip-requirements.txt b/pip-requirements.txt index 7078dd05..4e1c8ad5 100644 --- a/pip-requirements.txt +++ b/pip-requirements.txt @@ -8,3 +8,11 @@ pandas flake8 coveralls pytest-html +pytest-xdist +sphinx_rtd_theme +sphinx +nbsphinx +fastkde +getdist +nbstripout +mpi4py diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..8a66457d --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,7 @@ +[build-system] +requires = [ + "setuptools", + "wheel", + "cython", + "numpy", +] diff --git a/setup.cfg b/setup.cfg index c47123ad..2866e26b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,19 +1,11 @@ -[bumpversion] -current_version = 2.0.1 -commit = True -tag = True - -[bumpversion:file:setup.py] -search = version='{current_version}' -replace = version='{new_version}' - -[bumpversion:file:ultranest/__init__.py] -search = __version__ = '{current_version}' -replace = __version__ = '{new_version}' - [flake8] +style = numpy +check-return-types = False exclude = docs -ignore = E501,F401,E128,E231,E124 +extend-ignore = E501,F401,E128,E231,E124,SIM114,DOC105,DOC106,DOC107,DOC301,DOC501,DOC503,DOC203,B006,SIM102,SIM113,DOC202,DOC403,DOC404 +per-file-ignores = + ultranest/plot.py: B006 + ultranest/integrator.py: B006 [aliases] # Define setup.py command aliases here @@ -28,5 +20,8 @@ addopts = --junitxml=test-reports/junit.xml --html=tests/reports/index.html [pycodestyle] -ignore = E231 +count = False +ignore = W191,W291,W293,E231 +max-line-length = 160 +statistics = False diff --git a/setup.py b/setup.py index 8d530f49..1e2d9157 100644 --- a/setup.py +++ b/setup.py @@ -6,23 +6,32 @@ except: from distutils.core import setup +import re from Cython.Build import cythonize from distutils.extension import Extension from Cython.Distutils import build_ext -extra_include_dirs = [] +extra_include_dirs = ['.'] try: import numpy - extra_include_dirs = [numpy.get_include()] + extra_include_dirs += [numpy.get_include()] except: pass +ext_args = dict( + include_dirs=extra_include_dirs, + extra_compile_args=['-O3'], + extra_link_args=['-O3'], +) + -with open('README.rst') as readme_file: +with open('README.rst', encoding="utf-8") as readme_file: readme = readme_file.read() -with open('HISTORY.rst') as history_file: - history = history_file.read() +with open('HISTORY.rst', encoding="utf-8") as history_file: + history = re.sub(r':py:class:`([^`]+)`', r'\1', + history_file.read()) + requirements = ['numpy', 'cython', 'matplotlib', 'corner'] @@ -49,8 +58,12 @@ ], description="Fit and compare complex models reliably and rapidly. Advanced Nested Sampling.", install_requires=requirements, - ext_modules = [Extension('ultranest.mlfriends', ["ultranest/mlfriends.pyx"], - include_dirs=['.'] + extra_include_dirs)], + ext_modules = cythonize([ + Extension('ultranest.mlfriends', ["ultranest/mlfriends.pyx"], + **ext_args), + Extension('ultranest.stepfuncs', ["ultranest/stepfuncs.pyx"], + **ext_args), + ]), license="GNU General Public License v3", long_description=readme + '\n\n' + history, include_package_data=True, @@ -61,7 +74,7 @@ test_suite='tests', tests_require=test_requirements, url='https://github.com/JohannesBuchner/ultranest', - version='3.3.1', + version='4.5.0', zip_safe=False, cmdclass={'build_ext': build_ext}, ) diff --git a/tests/test_calibrator.py b/tests/test_calibrator.py new file mode 100644 index 00000000..9053062d --- /dev/null +++ b/tests/test_calibrator.py @@ -0,0 +1,77 @@ +import numpy as np +import ultranest +import ultranest.stepsampler +import ultranest.calibrator +import scipy.stats + + +def test_calibrator(plot=False): + # velocity dispersions of dwarf galaxies by van Dokkum et al., Nature, 555, 629 https://arxiv.org/abs/1803.10237v1 + + values = np.array([15, 4, 2, 11, 1, -2, -1, -14, -39, -3]) + values_lo = np.array([7, 16, 6, 3, 6, 5, 10, 6, 11, 13]) + values_hi = np.array([7, 15, 8, 3, 6, 6, 10, 7, 14, 14]) + + n_data = len(values) + + np.random.seed(42) + + samples = [] + + for i in range(n_data): + # draw normal random points + u = np.random.normal(size=400) + v = values[i] + np.where(u < 0, u * values_lo[i], u * values_hi[i]) + + samples.append(v) + + samples = np.array(samples) + + # Define functions inside test_calibrator to access samples and n_data + def prior_transform(cube): + # the argument, cube, consists of values from 0 to 1 + # we have to convert them to physical scales + + params = cube.copy() + # let slope go from -3 to +3 + lo = -100 + hi = +100 + params[0] = cube[0] * (hi - lo) + lo + # let scatter go from 1 to 1000 + lo = np.log10(1) + hi = np.log10(1000) + params[1] = 10**(cube[1] * (hi - lo) + lo) + return params + + def log_likelihood(params): + # unpack the current parameters: + mean, scatter = params + + # compute the probability of each sample + probs_samples = scipy.stats.norm(mean, scatter).pdf(samples) + # average over each galaxy, because we assume one of the points is the correct one (logical OR) + probs_objects = probs_samples.mean(axis=1) + assert len(probs_objects) == n_data + # multiply over the galaxies, because we assume our model holds true for all objects (logical AND) + # for numerical stability, we work in log and avoid zeros + loglike = np.log(probs_objects + 1e-100).sum() + return loglike + + parameters = ['mean', 'scatter'] + + sampler = ultranest.calibrator.ReactiveNestedCalibrator( + parameters, log_likelihood, prior_transform, log_dir="logs" + ) + + sampler.stepsampler = ultranest.stepsampler.SliceSampler( + nsteps=len(parameters), + generate_direction=ultranest.stepsampler.generate_region_oriented_direction + ) + + sampler.run(min_num_live_points=400) + + if plot: + sampler.plot() + +if __name__ == '__main__': + test_calibrator(plot=True) \ No newline at end of file diff --git a/tests/test_clustering.py b/tests/test_clustering.py index 8206b2f3..1f8c9a01 100644 --- a/tests/test_clustering.py +++ b/tests/test_clustering.py @@ -4,7 +4,7 @@ import matplotlib.pyplot as plt from ultranest.utils import create_logger from ultranest import ReactiveNestedSampler -from ultranest.mlfriends import MLFriends +from ultranest.mlfriends import MLFriends, AffineLayer, LocalAffineLayer here = os.path.dirname(__file__) @@ -55,16 +55,41 @@ def test_clusteringcase(): plt.close() +def test_subtract_nearby(): + from ultranest.mlfriends import subtract_nearby + + rng = np.random.RandomState(2) + u = rng.uniform(size=(20, 2)) + u[:10,:] += 10 + assert not np.all(np.abs(u) < 0.5) + print(u) + overlapped_points = subtract_nearby(u, 1.0) + print(overlapped_points) + assert np.all(np.abs(overlapped_points) < 0.5) + + u = rng.uniform(size=(200, 2)) + u[:100,0] = rng.uniform(0, 10, size=100) + u[100:,1] = rng.uniform(0, 10, size=100) + print(u) + assert not np.all(np.abs(u) < 0.5) + overlapped_points = subtract_nearby(u, 1.0) + print(overlapped_points) + print(overlapped_points.min(axis=0), overlapped_points.max(axis=0)) + assert np.all(np.abs(overlapped_points) < 0.6) + + def test_clusteringcase_eggbox(): from ultranest.mlfriends import update_clusters, ScalingLayer, MLFriends points = np.loadtxt(os.path.join(here, "eggboxregion.txt")) transformLayer = ScalingLayer() transformLayer.optimize(points, points) - region = MLFriends(points, transformLayer) - maxr = region.compute_maxradiussq(nbootstraps=30) - assert 1e-10 < maxr < 5e-10 - print('maxradius:', maxr) - nclusters, clusteridxs, overlapped_points = update_clusters(points, points, maxr) + for seed in range(10): + np.random.seed(seed) + region = MLFriends(points, transformLayer) + maxr = region.compute_maxradiussq(nbootstraps=30) + assert 1e-10 < maxr < 6e-10 + print('maxradius:', maxr) + nclusters, clusteridxs, overlapped_points = update_clusters(points, points, maxr) # plt.title('nclusters: %d' % nclusters) # for i in np.unique(clusteridxs): # x, y = points[clusteridxs == i].transpose() @@ -85,6 +110,7 @@ def __init__(self): self.log = True self.logger = create_logger("mock") self.region_class = MLFriends + self.transform_layer_class = AffineLayer def test_overclustering_eggbox_txt(): diff --git a/tests/test_hotstart.py b/tests/test_hotstart.py index 0e9fcef1..6d00f05b 100644 --- a/tests/test_hotstart.py +++ b/tests/test_hotstart.py @@ -3,7 +3,12 @@ import scipy.stats from numpy import log10 from ultranest import ReactiveNestedSampler +from ultranest.utils import vectorize +from ultranest.integrator import warmstart_from_similar_file from ultranest.hotstart import reuse_samples, get_extended_auxiliary_problem +from ultranest.hotstart import compute_quantile_intervals, get_auxiliary_contbox_parameterization, compute_quantile_intervals_refined +import os +import tempfile rng_data = np.random.RandomState(42) Ndata = 100 @@ -23,6 +28,114 @@ def log_likelihood(params): mean, sigma = params return scipy.stats.norm.logpdf(y, mean, sigma).sum() +def extended_prior_transform(x): + z = np.empty(3) + z[0] = x[0] * 2000 - 1000 + z[1] = 10**(x[1] * 4 - 2) + z[2] = 2 * np.sqrt(2 * np.log(2)) * z[1] + return z + +def extended_log_likelihood(params): + mean, sigma, fwhm = params + return scipy.stats.norm.logpdf(y, mean, sigma).sum() + +def test_contbox_hotstart(): + rng_samples = np.random.RandomState(43) + N = 100000 + samples = rng_samples.normal(0.1, 1e-6, size=(N,2)) + samples[:,1] = rng_samples.uniform(size=N) + weights = (np.ones(N) / N).reshape((-1,1)) + logl = weights * 0 + + steps = [0.1, 0.01] + ulos, uhis = compute_quantile_intervals(steps, samples, weights) + print("quantiles:", ulos) + print("quantiles:", uhis) + ulos2, uhis2, uinterpspace = compute_quantile_intervals_refined(steps, samples, weights) + print("refined quantiles:", ulos2) + print("refined quantiles:", uhis2) + print("interpolation steps:", uinterpspace) + assert np.diff(ulos, axis=0).shape == (2,2), ulos + assert (np.diff(uinterpspace) > 0).all(), uinterpspace + assert (np.diff(ulos, axis=0) < 0).all(), (ulos, uhis) + assert (np.diff(uhis, axis=0) > 0).all(), (ulos, uhis) + assert (np.diff(ulos2, axis=0) < 0).all(), (ulos2, uhis2) + assert (np.diff(uhis2, axis=0) > 0).all(), (ulos2, uhis2) + assert ulos.shape == (2+1, len(steps)), (uhis.shape, ulos.shape) + assert uhis.shape == ulos.shape, (uhis.shape, ulos.shape) + assert len(uinterpspace) == len(uhis2) + assert len(uinterpspace) == len(uhis2) + tol = dict(atol=1e-3, rtol=0.01) + for i in 1, 0: + for j, q in enumerate(steps): + expectation = np.quantile(samples[:,i], q) + actual = ulos[j,i] + print(i, j, q, expectation, actual) + assert np.isclose(expectation, actual, **tol), (i, j, q, expectation, actual) + expectation = np.quantile(samples[:,i], 1-q) + actual = uhis[j,i] + print(i, j, 1-q, expectation, actual) + assert np.isclose(expectation, actual, **tol), (i, j, 1-q, expectation, actual) + + aux_param_names, aux_loglike, aux_transform, vectorized = get_auxiliary_contbox_parameterization( + parameters, loglike=log_likelihood, transform=prior_transform, + vectorized=False, upoints=samples, uweights=weights, + ) + assert aux_param_names == parameters + ['aux_logweight'], (aux_param_names, parameters) + p = aux_transform(np.random.uniform(size=3)) + assert p.shape == (len(aux_param_names),) + L = float(aux_loglike(p)) + print(L) + del aux_param_names, aux_loglike, aux_transform + + aux_param_names, aux_vloglike, aux_vtransform, vectorized = get_auxiliary_contbox_parameterization( + parameters, loglike=vectorize(log_likelihood), transform=vectorize(prior_transform), + vectorized=True, upoints=samples, uweights=weights, + ) + print(aux_param_names, parameters) + assert aux_param_names == parameters + ['aux_logweight'], (aux_param_names, parameters) + p = aux_vtransform(np.random.uniform(size=(11, 3))) + assert p.shape == (11, len(aux_param_names)), p.shape + L = aux_vloglike(p) + assert L.shape == (11,), L.shape + print(L) + del aux_param_names, aux_vloglike, aux_vtransform + + with tempfile.TemporaryDirectory() as tmpdirname: + tmpfilename = os.path.join(tmpdirname, 'weighted_posterior_samples.txt') + print(tmpfilename) + np.savetxt( + tmpfilename, + np.hstack((weights, logl, samples)), + header='weight logl mean scatter', + fmt='%f' + ) + aux_param_names, aux_loglike, aux_transform, vectorized = warmstart_from_similar_file( + tmpfilename, + parameters, + extended_log_likelihood, + extended_prior_transform, + vectorized=False, + ) + assert aux_param_names == parameters + ['aux_logweight'], (aux_param_names, parameters) + p = aux_transform(np.random.uniform(size=3)) + assert p.shape == (len(aux_param_names)+1,) + L = float(aux_loglike(p)) + print(L) + aux_param_names, aux_vloglike, aux_vtransform, vectorized = warmstart_from_similar_file( + tmpfilename, + parameters, + vectorize(extended_log_likelihood), + vectorize(extended_prior_transform), + vectorized=True, + ) + assert aux_param_names == parameters + ['aux_logweight'], (aux_param_names, parameters) + p = aux_vtransform(np.random.uniform(size=(11, 3))) + assert p.shape == (11, len(aux_param_names)+1) + L = aux_vloglike(p) + assert L.shape == (11,) + print(L) + def test_hotstart_SLOW(): np.random.seed(2) ctr = np.array([(42.0 + 1000) / 2000, (log10(0.1) + 2) / 4]) @@ -89,4 +202,5 @@ def test_hotstart_SLOW(): assert 0.5 < (ref_results['posterior']['stdev'][1] / rec_results2['posterior']['stdev'][1]) < 1.5, (ref_results['posterior'], rec_results2['posterior']) if __name__ == '__main__': - test_hotstart() + test_hotstart_SLOW() + test_contbox_hotstart() diff --git a/tests/test_netiterintegrate.py b/tests/test_netiterintegrate.py index ec6bd974..8380aac4 100644 --- a/tests/test_netiterintegrate.py +++ b/tests/test_netiterintegrate.py @@ -186,7 +186,7 @@ def create_node(pointstore, Lmin): main_iterator.passing_node(node, active_values) for it, rootids in iterator_roots: if rootid in rootids: - mask = np.in1d(active_rootids, rootids, assume_unique=True) + mask = np.isin(active_rootids, rootids, assume_unique=True) #mask1 = np.array([rootid2 in rootids for rootid2 in active_rootids]) #assert (mask1 == mask).all(), (mask1, mask) it.passing_node(node, active_values[mask]) diff --git a/tests/test_ordertest.py b/tests/test_ordertest.py index 9301d345..e03c47fd 100644 --- a/tests/test_ordertest.py +++ b/tests/test_ordertest.py @@ -1,15 +1,13 @@ from __future__ import print_function, division import numpy as np +import pytest from ultranest.ordertest import UniformOrderAccumulator, infinite_U_zscore def test_invalid_order(): sample_acc = UniformOrderAccumulator() sample_acc.add(2, 3) - try: + with pytest.raises(ValueError): sample_acc.add(4, 3) - assert False - except ValueError: - pass def test_diff_expand(): sample_acc = UniformOrderAccumulator() diff --git a/tests/test_plot.py b/tests/test_plot.py new file mode 100644 index 00000000..bccd9d1d --- /dev/null +++ b/tests/test_plot.py @@ -0,0 +1,86 @@ +import numpy as np +import tempfile +import os +from ultranest.plot import PredictionBand, highest_density_interval_from_samples +from numpy.testing import assert_allclose +import matplotlib.pyplot as plt +import pytest + +def test_PredictionBand(): + + import numpy + chain = numpy.random.uniform(size=(20, 2)) + + + x = numpy.linspace(0, 1, 100) + band = PredictionBand(x) + for c in chain: + band.add(c[0] * x + c[1]) + # add median line. As an option a matplotlib ax can be given. + band.line(color='k') + # add 1 sigma quantile + band.shade(color='k', alpha=0.3) + # add wider quantile + band.shade(q=0.01, color='gray', alpha=0.1) + plt.savefig('test-predictionband.pdf') + plt.close() + + # add median line. As an option a matplotlib ax can be given. + fig, (ax1, ax2) = plt.subplots(1, 2) + band.line(color='k', ax=ax1) + band.line(color='k', ax=ax2) + # add 1 sigma quantile + with pytest.raises(ValueError): + band.shade(q=0.6, ax=ax1) + with pytest.raises(ValueError): + band.shade(q=np.nan, ax=ax2) + band.shade(q=0.01, color='gray', alpha=0.3, ax=ax1) + plt.savefig('test-predictionband2.pdf') + plt.close() + + +def test_hdi(): + rng = np.random.RandomState(2) + x = rng.normal(size=100000) + xmid, xerrlo, xerrhi = highest_density_interval_from_samples(x, xlo=None, xhi=None, probability_level=0.68) + assert -0.02 < xmid < 0.02 + assert 0.98 < xerrlo < 1.02 + assert 0.98 < xerrhi < 1.02 + + xpmid, xperrlo, xperrhi = highest_density_interval_from_samples(np.abs(x), xlo=0, xhi=None, probability_level=0.68) + assert 0 <= xpmid < 0.02 + assert 0.98 < xperrhi < 1.02 + assert 0 <= xperrlo < 0.02 + + xpmid, xperrlo, xperrhi = highest_density_interval_from_samples(-np.abs(x), xlo=None, xhi=0, probability_level=0.68) + assert -0.02 < xpmid <= 0 + assert 0.98 < xperrlo < 1.02 + assert 0 <= xperrhi < 0.02 + + xmid, xerrlo, xerrhi = highest_density_interval_from_samples(x, xlo=None, xhi=None, probability_level=0.955) + assert -0.02 < xmid < 0.02 + assert 1.98 < xerrlo < 2.02 + assert 1.98 < xerrhi < 2.02 + + xpmid, xperrlo, xperrhi = highest_density_interval_from_samples(np.abs(x), xlo=0, xhi=None, probability_level=0.955) + assert 0 <= xpmid < 0.02 + assert 1.98 < xperrhi < 2.02 + assert 0 <= xperrlo < 0.02 + + xpmid, xperrlo, xperrhi = highest_density_interval_from_samples(-np.abs(x), xlo=None, xhi=0, probability_level=0.955) + assert -0.02 < xpmid <= 0 + assert 1.98 < xperrlo < 2.02 + assert 0 <= xperrhi < 0.02 + + u = rng.beta(2, 2, size=100000) + umid, uerrlo, uerrhi = highest_density_interval_from_samples(u, xlo=0, xhi=1, probability_level=0.68) + print(umid, uerrlo, uerrhi) + assert abs(umid - 0.5) < 0.02, umid + assert abs(uerrlo - 0.25) < 0.02, umid + assert abs(uerrhi - 0.25) < 0.02, umid + + umid, uerrlo, uerrhi = highest_density_interval_from_samples(u, xlo=None, xhi=None, probability_level=0.68) + print(umid, uerrlo, uerrhi) + assert abs(umid - 0.5) < 0.02, umid + assert abs(uerrlo - 0.25) < 0.02, umid + assert abs(uerrhi - 0.25) < 0.02, umid diff --git a/tests/test_popstepsampling.py b/tests/test_popstepsampling.py new file mode 100644 index 00000000..01e9c865 --- /dev/null +++ b/tests/test_popstepsampling.py @@ -0,0 +1,302 @@ +import os +import tempfile +import numpy as np + +from ultranest import ReactiveNestedSampler +from ultranest.mlfriends import AffineLayer, ScalingLayer, MLFriends, RobustEllipsoidRegion, SimpleRegion +from ultranest.popstepsampler import PopulationSliceSampler, PopulationRandomWalkSampler, PopulationSimpleSliceSampler +from ultranest.popstepsampler import generate_cube_oriented_direction, generate_random_direction, generate_cube_oriented_direction_scaled +from ultranest.popstepsampler import generate_region_oriented_direction, generate_region_random_direction +from ultranest.popstepsampler import slice_limit_to_unitcube,slice_limit_to_scale +from ultranest.popstepsampler import int_dtype + +def make_region(ndim, us=None, nlive=400): + if us is None: + us = np.random.uniform(size=(nlive, ndim)) + + if ndim > 1: + transformLayer = AffineLayer() + else: + transformLayer = ScalingLayer() + transformLayer.optimize(us, us) + region = MLFriends(us, transformLayer) + region.maxradiussq, region.enlarge = region.compute_enlargement(nbootstraps=30) + region.create_ellipsoid(minvol=1.0) + return region + +def loglike_vectorized(z): + a = np.array([-0.5 * sum([((xi - 0.7 + i*0.001)/0.1)**2 for i, xi in enumerate(x)]) for x in z]) + b = np.array([-0.5 * sum([((xi - 0.3 - i*0.001)/0.1)**2 for i, xi in enumerate(x)]) for x in z]) + return np.logaddexp(a, b) + +def loglike(x): + a = -0.5 * sum([((xi - 0.7 + i*0.001)/0.1)**2 for i, xi in enumerate(x)]) + b = -0.5 * sum([((xi - 0.3 - i*0.001)/0.1)**2 for i, xi in enumerate(x)]) + return np.logaddexp(a, b) + +def transform(x): + return x # * 10. - 5. + +paramnames = ['param%d' % i for i in range(3)] + +def test_stepsampler_cubeslice(plot=False): + np.random.seed(3) + nsteps = np.random.randint(10, 50) + popsize = np.random.randint(1, 20) + sampler = ReactiveNestedSampler(paramnames, loglike_vectorized, transform=transform, vectorized=True) + + sampler.stepsampler = PopulationSliceSampler( + popsize=popsize, nsteps=nsteps, + generate_direction=generate_cube_oriented_direction, + log=True, + ) + r = sampler.run(viz_callback=None, log_interval=50) + sampler.print_results() + a = (np.abs(r['samples'] - 0.7) < 0.1).all(axis=1) + b = (np.abs(r['samples'] - 0.3) < 0.1).all(axis=1) + assert a.sum() > 1 + assert b.sum() > 1 + + with tempfile.TemporaryDirectory() as tempdir: + prefix = os.path.join(tempdir, 'test-stepsampler') + sampler.stepsampler.plot(prefix + '-plot.pdf') + assert os.path.exists(prefix + '-plot.pdf') + sampler.stepsampler.plot_jump_diagnostic_histogram(prefix + '-plot-jumps.pdf') + assert os.path.exists(prefix + '-plot-jumps.pdf') + sampler.stepsampler.print_diagnostic() + print(sampler.stepsampler) + print(sampler.stepsampler.status) + +def test_stepsampler_cubegausswalk(plot=False): + np.random.seed(2) + nsteps = np.random.randint(10, 50) + popsize = np.random.randint(1, 20) + sampler = ReactiveNestedSampler(paramnames, loglike_vectorized, transform=transform, vectorized=True) + + sampler.stepsampler = PopulationRandomWalkSampler( + popsize=popsize, nsteps=nsteps, + generate_direction=generate_cube_oriented_direction, + scale=0.1, log=True, + ) + r = sampler.run(viz_callback=None, log_interval=50, max_iters=200, max_num_improvement_loops=0) + sampler.print_results() + a = (np.abs(r['samples'] - 0.7) < 0.1).all(axis=1) + b = (np.abs(r['samples'] - 0.3) < 0.1).all(axis=1) + assert a.sum() > 1 + assert b.sum() > 1 + +def test_stepsampler_randomSimSlice(plot=False): + np.random.seed(4) + nsteps = np.random.randint(10, 50) + popsize = np.random.randint(1, 20) + sampler = ReactiveNestedSampler(paramnames, loglike_vectorized, transform=transform, vectorized=True) + + sampler.stepsampler = PopulationSimpleSliceSampler( + popsize=popsize, nsteps=nsteps, + generate_direction=generate_random_direction, + ) + r = sampler.run(viz_callback=None, log_interval=50, max_iters=200, max_num_improvement_loops=0) + sampler.print_results() + a = (np.abs(r['samples'] - 0.7) < 0.1).all(axis=1) + b = (np.abs(r['samples'] - 0.3) < 0.1).all(axis=1) + assert a.sum() > 1 + assert b.sum() > 1 + + + + with tempfile.TemporaryDirectory() as tempdir: + prefix = os.path.join(tempdir, 'test-stepsampler') + sampler.stepsampler.plot(prefix + '-plot.pdf') + assert os.path.exists(prefix + '-plot.pdf') + sampler.stepsampler.plot_jump_diagnostic_histogram(prefix + '-plot-jumps.pdf') + assert os.path.exists(prefix + '-plot-jumps.pdf') + sampler.stepsampler.print_diagnostic() + print(sampler.stepsampler) + +def test_direction_proposals(): + proposals = [generate_cube_oriented_direction, generate_random_direction, + generate_region_oriented_direction, generate_region_random_direction] + + points = np.random.uniform(size=(100, 10)) + minvol = 1.0 + + scale = 1. # np.random.uniform() + for layer in AffineLayer, ScalingLayer: + transformLayer = layer() + transformLayer.optimize(points, points) + for region_class in MLFriends, RobustEllipsoidRegion, SimpleRegion: + region = region_class(points, transformLayer) + r, f = region.compute_enlargement(minvol=minvol, nbootstraps=30) + region.maxradiussq = r + region.enlarge = f + region.create_ellipsoid(minvol=minvol) + + for prop in proposals: + print("test of proposal:", prop, "with region:", region_class, "layer:", layer) + directions = prop(points, region, scale=scale) + assert directions.shape == points.shape, (directions.shape, points.shape) + #assert np.allclose(norms, scale), (norms, scale) + + +def test_slice_limit(): + + slice_limit_func = [slice_limit_to_unitcube, slice_limit_to_scale] + fake_tleft = [-0.5, -0.2, -1.5] + fake_tright = [0.2, 2.4, 0.2] + + fake_tleft_scale = [-0.5, -0.2, -1.] + fake_tright_scale = [0.2, 1.0, 0.2] + + true_tleft = [fake_tleft, fake_tleft_scale] + true_tright = [fake_tright, fake_tright_scale] + + for i,func in enumerate(slice_limit_func): + tleft, tright = func(fake_tleft, fake_tright) + assert np.allclose(tleft, true_tleft[i]), (tleft, true_tleft[i]) + assert np.allclose(tright, true_tright[i]), (tright, true_tright[i]) + + +from ultranest.stepfuncs import update_vectorised_slice_sampler + +def test_update_slice_sampler(): + """ + Test goal: Testing the update in each different typical cases. + + There are 3 points searched with 4 points sampled on their slices: + - In the first case, no point is satisfying the Lmin condition. + The functions should just update the slice limits and keep the status + unchanged. + - In the second case, one point is satisfying the Lmin condition. + But it will be discarded as it will be outside the slice limits. The + function should update the slice limits and keep the same status. + - In the third case, one point is satisfying the Lmin condition and + the slice limits. The function should update the slice limits and change + the status. + + The workers should be split among the 2 unfinished points at the end. + """ + + worker_running = np.array([0,0,0,0,1,1,1,1,2,2,2,2], dtype=int_dtype) + popsize = 12 + status = np.zeros(12, dtype=int_dtype) + status[3:] = 1 + Lmin = 1. + shrink = 1.0 + proposed_L = np.array([-12.,0.5,0.09,-2.,0.4,-5,2.4,0.3,-3.4,1.2,0.1,0.5]) + tleft = -np.ones(12) + tright = np.ones(12) + t = np.array([-0.8,-0.2,0.4,-0.5,-0.3,0.9,-0.7,0.2,-0.8,0.5,-0.4,0.6]) + proposed_u = np.array([[0.,0.,0.,0.,1.,1.,1.,1.,2.,2.5,2.,2.]]).T + proposed_p = np.array([[0.,0.,0.,0.,1.,1.,1.,1.,2.,2.5,2.,2.]]).T + allL = np.zeros(12) + allu = np.zeros((12,1)) + allp = np.zeros((12,1)) + + + tleft, tright, worker_running, status, allu, allL, allp,discarded= update_vectorised_slice_sampler( + t, tleft,tright,proposed_L,proposed_u,proposed_p,worker_running,status,Lmin,shrink,allu,allL,allp,popsize) + + true_worker= np.array([0,1,0,1,0,1,0,1,0,1,0,1]) + true_status = np.array([0,0,1,1,1,1,1,1,1,1,1,1]) + true_allL = np.array([0.,0.,1.2,0,0,0,0,0,0,0,0,0]) + true_allu = np.array([[0.,0.,2.5,0,0,0,0,0,0,0,0,0]]).T + true_allp = np.array([[0.,0.,2.5,0,0,0,0,0,0,0,0,0]]).T + true_discarded = 1 + true_tleft = np.array([-0.2,-.3,-0.4,-1,-1,-1,-1,-1,-1,-1,-1,-1]) + true_tright = np.array([0.4,0.2,0.5,1,1,1,1,1,1,1,1,1]) + + assert np.allclose(worker_running, true_worker), (worker_running, true_worker) + assert np.allclose(status, true_status), (status, true_status) + assert np.allclose(allL, true_allL), (allL, true_allL) + assert np.allclose(allu, true_allu), (allu, true_allu) + assert np.allclose(allp, true_allp), (allp, true_allp) + assert np.allclose(discarded, true_discarded), (discarded, true_discarded) + assert np.allclose(tleft, true_tleft), (tleft, true_tleft) + assert np.allclose(tright, true_tright), (tright, true_tright) + + +# aim at checking the sanity of the results of +# one iteration of the slice sampler. +def test_SimpleSliceSampler_SLOW(seed=4): + np.random.seed(seed) + nsteps = 1 + popsize = 100 + ndim = 10 + sampler = ReactiveNestedSampler(paramnames, loglike_vectorized, transform=transform, vectorized=True) + + sampler.stepsampler = PopulationSimpleSliceSampler( + popsize=popsize, nsteps=nsteps, + generate_direction=generate_random_direction, + ) + stepsampler = sampler.stepsampler + # start with a random point in the unit cube + us = (np.random.uniform(size=(popsize, ndim))-0.5)*0.9+0.5 + Ls = loglike_vectorized(us) + Lmin = np.min(Ls) + + u,L=np.zeros((popsize,ndim)),np.zeros(popsize) + + # initialising a region + #print(us) + region= RobustEllipsoidRegion(us, AffineLayer()) + region.maxradiussq, region.enlarge = region.compute_enlargement(nbootstraps=30) + region.create_ellipsoid(minvol=1.0) + + # resetting the seed to check the slice axes + np.random.seed(seed) + for i in range(popsize): + u[i],_,L[i],_= stepsampler.__next__(region, Lmin, us.copy(), Ls.copy(), transform, loglike_vectorized, test=True) + + # Basic check + assert (L>Lmin).all(), (L,Lmin) # Lmin check + assert (u>0).all() and (u<1).all(), u # u in the unit cube check + + np.random.seed(seed) + # resetting the random generation inside the sampler + np.random.randint(0, us.shape[0], size=stepsampler.popsize) + stepsampler.scale_jitter_func() + + # Getting the slice axes + slice_axes = stepsampler.generate_direction(us.copy(), region,scale= 1.0) + for i in range(popsize): + v = (u[i,:] - us[i,:]) / slice_axes[i, :] + mean_v = np.mean(v) + assert np.allclose(mean_v, v, atol=1e-10), (mean_v, v) + + + + +def test_direction_proposal_values(): + ndim = 10 + np.random.seed(12) + region = make_region(ndim, nlive=400) + ui = region.u[::2] + + scale = np.random.uniform() + vcube = generate_cube_oriented_direction(ui, region, scale) + assert vcube.shape == ui.shape + assert vcube.sum(axis=1).shape == (len(ui),) + assert ((vcube != 0).sum(axis=1) == 1).all(), vcube + assert np.allclose(np.linalg.norm(vcube, axis=1), scale), (vcube, np.linalg.norm(vcube, axis=1), scale) + + vharm = generate_random_direction(ui, region, scale) + assert (vharm != 0).all(), vharm + vregionslice = generate_region_oriented_direction(ui, region, scale) + assert (vregionslice != 0).all(), vregionslice + vregionharm = generate_region_random_direction(ui, region, scale) + assert (vregionharm != 0).all(), vregionharm + vcubestd = generate_cube_oriented_direction_scaled(ui, region, scale) + assert vcubestd.shape == ui.shape + assert vcubestd.sum(axis=1).shape == (len(ui),) + assert ((vcubestd != 0).sum(axis=1) == 1).all(), vcubestd + + +if __name__ == '__main__': + #test_stepsampler_cubegausswalk() + #test_stepsampler_randomSimSlice() + #test_direction_proposals() + test_slice_limit() + #test_update_slice_sampler() + #Test_SimpleSliceSampler(4) + + diff --git a/tests/test_regionsampling.py b/tests/test_regionsampling.py index 5a17ec7f..634256e2 100644 --- a/tests/test_regionsampling.py +++ b/tests/test_regionsampling.py @@ -2,6 +2,7 @@ import os import matplotlib.pyplot as plt from ultranest.mlfriends import ScalingLayer, AffineLayer, MLFriends +from ultranest.mlfriends import RobustEllipsoidRegion, SimpleRegion, WrappingEllipsoid from numpy.testing import assert_allclose here = os.path.dirname(__file__) @@ -141,6 +142,56 @@ def test_region_mean_distances(): assert np.isclose(meandist, d / N), (meandist, d, N) +def test_ellipsoids(): + tpoints = np.random.uniform(0.4, 0.6, size=(1000, 1)) + tregion = WrappingEllipsoid(tpoints) + print(tregion.variable_dims) + tregion.enlarge = tregion.compute_enlargement(nbootstraps=30) + tregion.create_ellipsoid() + + for umax in 0.6, 0.5: + print() + print(umax) + points = np.random.uniform(0.4, 0.6, size=(1000, 3)) + points = points[points[:,0] < umax] + tpoints = points * 10 + tpoints[:,0] = np.floor(tpoints[:,0]) + print(points, tpoints) + + transformLayer = AffineLayer(wrapped_dims=[]) + transformLayer.optimize(points, points) + + region = MLFriends(points, transformLayer) + region.maxradiussq, region.enlarge = region.compute_enlargement(nbootstraps=30) + region.create_ellipsoid() + inside = region.inside(points) + assert inside.shape == (len(points),), (inside.shape, points.shape) + assert inside.all() + + region = RobustEllipsoidRegion(points, transformLayer) + region.maxradiussq, region.enlarge = region.compute_enlargement(nbootstraps=30) + region.create_ellipsoid() + inside = region.inside(points) + assert inside.shape == (len(points),), (inside.shape, points.shape) + assert inside.all() + + region = SimpleRegion(points, transformLayer) + region.maxradiussq, region.enlarge = region.compute_enlargement(nbootstraps=30) + region.create_ellipsoid() + inside = region.inside(points) + assert inside.shape == (len(points),), (inside.shape, points.shape) + assert inside.all() + + tregion = WrappingEllipsoid(tpoints) + print(tregion.variable_dims) + tregion.enlarge = tregion.compute_enlargement(nbootstraps=30) + tregion.create_ellipsoid() + inside = tregion.inside(tpoints) + assert inside.shape == (len(tpoints),), (inside.shape, tpoints.shape) + assert inside.all() + + if __name__ == '__main__': - test_region_sampling_scaling(plot=True) - test_region_sampling_affine(plot=True) + #test_region_sampling_scaling(plot=True) + #test_region_sampling_affine(plot=True) + test_ellipsoids() diff --git a/tests/test_run.py b/tests/test_run.py index 6537c5f9..6283e995 100644 --- a/tests/test_run.py +++ b/tests/test_run.py @@ -1,12 +1,214 @@ +import os import numpy as np import shutil import tempfile import pytest +import json +import pandas +from ultranest.mlfriends import MLFriends, ScalingLayer, AffineLayer, MaxPrincipleGapAffineLayer, LocalAffineLayer +from ultranest import NestedSampler, ReactiveNestedSampler, read_file +from ultranest.integrator import warmstart_from_similar_file, _update_region_bootstrap, _get_cumsum_range +import ultranest.mlfriends from numpy.testing import assert_allclose -def test_run(): - from ultranest import NestedSampler +def sample_ellipsoid(rng, nsamples, ndim, sigma=0.01, center=0.5): + """ Sample from a unit sphere with constant density """ + z = rng.normal(size=(nsamples, ndim)) + z /= ((z**2).sum(axis=1)**0.5).reshape((nsamples, 1)) + u = z * rng.uniform(size=(nsamples, 1))**(1./ndim) + return u * sigma + center + +def generate_two_blob_points(rng, d, Nlive1, Nlive2, offset2, sigma): + """ generate live points from two spheres """ + return np.vstack((sample_ellipsoid(rng, Nlive1, d, sigma=sigma), sample_ellipsoid(rng, Nlive2, d, sigma=sigma) + offset2)) + +def test_get_cumsum_range1(): + # cumulative probabilities are: array([0.1, 0.3, 0.6, 1. ]) + pi = np.array([0.1, 0.2, 0.3, 0.4]) + dp = 0.2 + ilo, ihi = _get_cumsum_range(pi, dp) + assert ilo == 1, ilo + assert ihi == 2, ihi + +def test_get_cumsum_range_equal_prob(): + p = np.ones(100) * 1.0 + p = p * 1. / p.sum() + print(p, p.sum()) + for percentile in 1, 5, 10, 20, 45: + ilo, ihi = _get_cumsum_range(p, percentile / 100.) + print(percentile, ilo, ihi, np.cumsum(p)) + print(np.cumsum(p)[ilo], np.cumsum(p)[ihi]) + # due to rounding issues, can slip to a lower index + assert ilo in (percentile, percentile - 1) + assert ihi in (100 - percentile - 1, 100 - percentile - 2) + assert np.cumsum(p)[ilo] >= percentile / 100. + assert np.cumsum(p)[ihi] <= 1 - percentile / 100. + assert p[ilo:ihi].sum() <= (1 - percentile / 100.) * 2, (p[ilo:ihi], p[ilo:ihi].sum(), percentile) + + +def test_get_cumsum_range_random_prob(): + np.random.seed(100) + for i in range(100): + size = int(10**np.random.uniform(0, 4)) + p = np.random.uniform(size=size) + p = p * 1. / p.sum() + dp = np.random.uniform() + ilo, ihi = _get_cumsum_range(p, dp) + print(dp, p, np.cumsum(p), '-->', ilo, ihi, np.cumsum(p)[ilo], np.cumsum(p)[ihi]) + # check that the selected interval contains the desired probability + assert p[ilo:ihi].sum() <= (1 - dp) * 2, (p[ilo:ihi], p[ilo:ihi].sum(), (1 - dp) * 2) + + +def test_failing_update_region_bootstrap(): + rng = np.random.RandomState(10) + u = rng.uniform(size=(200, 4)) + # make linearly dependent, so building a region should fail + u[:,-1] = u[:,0] + # boot-strap an affine layer after clustering + transformLayer = ScalingLayer() + transformLayer.optimize(u, u) + region = MLFriends(u, transformLayer) + with pytest.raises(np.linalg.LinAlgError): + _update_region_bootstrap(region, nbootstraps=30) + + +def test_clustering_recursion(plot=False): + # generate two blobs separated by 2 sigma + # check that they are *not* separated with AffineLayer+MLFriends + # check that they are separated with MaxPrincipleGapAffineLayer+MLFriends + nbootstraps = 30 + + sigma = 0.001 + d = 20 + + Nlive = 100 + Nlive1 = Nlive // 2 + Nlive2 = Nlive // 2 + + offset2 = 1.0 * sigma + + nwithclusters = 0 + nwithclusters2 = 0 + noverclustered = 0 + n2withclusters = 0 + n2withclusters2 = 0 + n2overclustered = 0 + gapped_nwithclusters = 0 + gapped_nwithclusters2 = 0 + gapped_noverclustered = 0 + + for seed in range(25): + rng = np.random.RandomState(54 + seed) + u = generate_two_blob_points(rng, d, Nlive1, Nlive2, offset2, sigma) + + # boot-strap an affine layer after clustering + transformLayer = AffineLayer() + transformLayer.optimize(u, u) + region = MLFriends(u, transformLayer) + _update_region_bootstrap(region, nbootstraps) + region.create_ellipsoid() + layer = transformLayer.create_new(u, region.maxradiussq) + nextregion = MLFriends(u, layer) + _update_region_bootstrap(nextregion, nbootstraps=30) + nextLayer = layer.create_new(u, nextregion.maxradiussq) + nextNextLayer = nextLayer.create_new(u, region.maxradiussq) + + nwithclusters += layer.nclusters + nwithclusters2 += nextLayer.nclusters + noverclustered += nextLayer.nclusters > 2 + if plot: + import matplotlib.pyplot as plt + plt.figure("AffineLayer", figsize=(20, 20)) + plt.subplot(5, 5, seed + 1) + plt.title('%d -> %d -> %d' % (transformLayer.nclusters, nextLayer.nclusters, nextNextLayer.nclusters)) + plt.scatter(u[:,0], u[:,1], label='points') + # Plot the principal vectors + plt.quiver(0.5, 0.5, transformLayer.invT[0, 0], transformLayer.invT[0, 1], angles='xy', scale_units='xy', scale=1, color='r', label='First Principal Vector') + plt.quiver(0.5, 0.5, transformLayer.invT[1, 0], transformLayer.invT[1, 1], angles='xy', scale_units='xy', scale=1, color='b', label='Second Principal Vector') + ylo, yhi = plt.ylim() + ymax = max(0.5 - ylo, yhi - 0.5) + xlo, xhi = plt.xlim() + xmax = max(0.5 - xlo, xhi - 0.5) + xymax = 1.5 * max(ymax, xmax) + plt.xlim(0.5 - xymax, 0.5 + xymax) + plt.ylim(0.5 - xymax, 0.5 + xymax) + + # boot-strap an affine layer after clustering + transformLayer = LocalAffineLayer() + transformLayer.optimize(u, u) + region = MLFriends(u, transformLayer) + _update_region_bootstrap(region, nbootstraps) + region.create_ellipsoid() + layer = transformLayer.create_new(u, region.maxradiussq) + nextregion = MLFriends(u, layer) + _update_region_bootstrap(nextregion, nbootstraps=30) + nextLayer = layer.create_new(u, nextregion.maxradiussq) + nextNextLayer = nextLayer.create_new(u, region.maxradiussq) + + n2withclusters += layer.nclusters + n2withclusters2 += nextLayer.nclusters + n2overclustered += nextLayer.nclusters > 2 + + # boot-strap an MaxPrincipleGapAffineLayer layer after clustering + transformLayer = MaxPrincipleGapAffineLayer() + transformLayer.optimize(u, u) + region = MLFriends(u, transformLayer) + _update_region_bootstrap(region, nbootstraps) + region.create_ellipsoid() + layer = transformLayer.create_new(u, region.maxradiussq) + nextregion = MLFriends(u, layer) + _update_region_bootstrap(nextregion, nbootstraps=30) + nextLayer = layer.create_new(u, nextregion.maxradiussq) + nextNextLayer = nextLayer.create_new(u, region.maxradiussq) + + gapped_nwithclusters += layer.nclusters + gapped_nwithclusters2 += nextLayer.nclusters + gapped_noverclustered += nextLayer.nclusters > 2 + if plot: + plt.figure("MaxPrincipleGapAffineLayer", figsize=(20, 20)) + plt.subplot(5, 5, seed + 1) + plt.title('%d -> %d -> %d' % (transformLayer.nclusters, nextLayer.nclusters, nextNextLayer.nclusters)) + plt.scatter(u[:,0], u[:,1], label='points') + # Plot the principal vectors + plt.quiver(0.5, 0.5, transformLayer.invT[0, 0], transformLayer.invT[0, 1], angles='xy', scale_units='xy', scale=1, color='r', label='First Principal Vector') + plt.quiver(0.5, 0.5, transformLayer.invT[1, 0], transformLayer.invT[1, 1], angles='xy', scale_units='xy', scale=1, color='b', label='Second Principal Vector') + ylo, yhi = plt.ylim() + ymax = max(0.5 - ylo, yhi - 0.5) + xlo, xhi = plt.xlim() + xmax = max(0.5 - xlo, xhi - 0.5) + xymax = 1.5 * max(ymax, xmax) + plt.xlim(0.5 - xymax, 0.5 + xymax) + plt.ylim(0.5 - xymax, 0.5 + xymax) + + if plot: + plt.savefig('layercov_MaxPrincipleGapAffineLayer.pdf') + plt.close() + plt.savefig('layercov_AffineLayer.pdf') + plt.close() + + print("clustering statistics: (%d runs)" % (seed+1)) + print(" number of clusters iteration 1, iteration 2, number of overclusterings") + print("AffineLayer:") + print(" ", nwithclusters, nwithclusters2, noverclustered) + print("LocalAffineLayer:") + print(" ", nwithclusters, nwithclusters2, noverclustered) + print("MaxPrincipleGapAffineLayer:") + print(" ", gapped_nwithclusters, gapped_nwithclusters2, gapped_noverclustered) + # with the affine layer we only see one cluster, because they are + # so close together and the covariance spans them + assert nwithclusters in (25, 26, 27) + assert nwithclusters2 in (25, 26, 27) + assert n2withclusters in (25, 26, 27) + assert n2withclusters2 in (25, 26, 27) + # MaxPrincipleGapAffineLayer builds a more local covariance + # so the subsequent iteration splits the cluster + assert gapped_nwithclusters in (25, 26, 27) + assert gapped_nwithclusters2 in (49, 50, 51, 52) + assert noverclustered in (0, 1, 2) + assert n2overclustered in (0, 1, 2) + assert gapped_noverclustered in (0, 1, 2) +def test_run(): def loglike(y): z = np.log10(y) a = np.array([-0.5 * sum([((xi - 0.83456 + i*0.1)/0.5)**2 for i, xi in enumerate(x)]) for x in z]) @@ -35,9 +237,6 @@ def transform(x): def test_dlogz_reactive_run_SLOW(): - from ultranest import ReactiveNestedSampler - import ultranest.mlfriends - def loglike(y): return -0.5 * np.sum(((y - 0.5)/0.001)**2, axis=1) @@ -55,10 +254,9 @@ def loglike(y): print("logzerr in iteration %d" % niter, results['logzerr']) print() print({k:v for k, v in results.items() if 'logzerr' in k}) - assert results['logzerr'] < 0.1 * 2 + assert results['logzerr'] < 0.1 * 3 def test_reactive_run(): - from ultranest import ReactiveNestedSampler np.random.seed(1) evals = set() @@ -116,8 +314,34 @@ def transform(x): sampler.plot() +def test_plateau_SLOW(): + def loglike(y): + a = -0.5 * ((y/0.1)**2).sum() + if a < -1: + return -1e100 + return a + + def transform(x): + return x * 2 - 1 + + paramnames = ['Hinz', 'Kunz'] + + sampler = ReactiveNestedSampler(paramnames, loglike, transform=transform) + print(sampler.run(min_num_live_points=400)['logz']) + print(sampler.run_sequence['nlive'][:-400]) + assert sampler.run_sequence['nlive'][-400] == 400, sampler.run_sequence['nlive'][:-400] + +def test_flat(): + def loglike(y): + return 0 + paramnames = ['Hinz', 'Kunz'] + + sampler = ReactiveNestedSampler(paramnames, loglike) + print(sampler.run(min_num_live_points=400)['logz']) + print(sampler.run_sequence['nlive'][:-400]) + + def test_reactive_run_extraparams(): - from ultranest import ReactiveNestedSampler np.random.seed(1) def loglike(z): @@ -137,7 +361,6 @@ def transform(x): sampler.plot() def test_return_summary(): - from ultranest import ReactiveNestedSampler sigma = np.array([0.1, 0.01]) centers = np.array([0.5, 0.75]) paramnames = ['a', 'b'] @@ -185,7 +408,6 @@ def transform(x): @pytest.mark.parametrize("dlogz", [2.0, 0.5, 0.1]) def test_run_resume(dlogz): - from ultranest import ReactiveNestedSampler sigma = 0.01 ndim = 1 @@ -228,9 +450,6 @@ def myadd(row): @pytest.mark.parametrize("storage_backend", ['hdf5', 'tsv', 'csv']) def test_reactive_run_resume_eggbox(storage_backend): - from ultranest import ReactiveNestedSampler - from ultranest import read_file - def loglike(z): chi = (np.cos(z / 2.)).prod(axis=1) loglike.ncalls += len(z) @@ -278,6 +497,65 @@ def transform(x): assert abs(r['ncall'] - ncalls) <= 2 * sampler.mpi_size, (i, r['ncall'], ncalls, r['ncall'] - ncalls) assert paramnames == r['paramnames'], 'paramnames should be in results' + results2 = json.load(open(folder + '/info/results.json')) + print('CSV content:') + print(open(folder + '/info/post_summary.csv').read()) + post_summary = pandas.read_csv(folder + '/info/post_summary.csv') + print(post_summary, post_summary.columns) + for k, v in r.items(): + if k in results2: + print("checking results[%s] ..." % k) + assert results2[k] == r[k], (k, results2[k], r[k]) + + assert r['paramnames'] == paramnames + samples = np.loadtxt(folder + '/chains/equal_weighted_post.txt', skiprows=1) + data = np.loadtxt(folder + '/chains/weighted_post.txt', skiprows=1) + data_u = np.loadtxt(folder + '/chains/weighted_post_untransformed.txt', skiprows=1) + assert (data[:,:2] == data_u[:,:2]).all() + + assert_allclose(samples.mean(axis=0), r['posterior']['mean']) + assert_allclose(np.median(samples, axis=0), r['posterior']['median']) + assert_allclose(np.std(samples, axis=0), r['posterior']['stdev']) + for k, v in r.items(): + if k == 'posterior': + for k1, v1 in v.items(): + if k1 == 'information_gain_bits': + continue + for param, value in zip(paramnames, v[k1]): + print("checking %s of parameter '%s':" % (k1, param), value) + assert np.isclose(post_summary[param + '_' + k1].values, value), (param, k1, post_summary[param + '_' + k1].values, value) + elif k == 'samples': + assert_allclose(samples, r['samples']) + elif k == 'paramnames': + assert v == paramnames + elif k == 'weighted_samples': + print(k, v.keys()) + assert_allclose(data[:,0], v['weights']) + assert_allclose(data[:,1], v['logl']) + assert_allclose(data[:,2:], v['points']) + assert_allclose(data_u[:,2:], v['upoints']) + elif k == 'maximum_likelihood': + print(k, v.keys()) + assert_allclose(data[-1,1], v['logl']) + assert_allclose(data[-1,2:], v['point']) + assert_allclose(data_u[-1,2:], v['point_untransformed']) + + elif k.startswith('logzerr') or '_bs' in k or 'Herr' in k: + print(" skipping", k, np.shape(v)) + #assert_allclose(r[k], v, atol=0.5) + elif k == 'insertion_order_MWW_test': + print('insertion_order_MWW_test:', r[k], v) + assert r[k] == v, (r[k], v) + else: + print(" ", k, np.shape(v)) + assert_allclose(r[k], v) + + logw = r['weighted_samples']['logw'] + v = r['weighted_samples']['points'] + L = r['weighted_samples']['logl'] + + assert results2['niter'] == len(r['samples']) + # the results are not exactly the same, because the sampling adds #ncalls = loglike.ncalls #sampler = ReactiveNestedSampler(paramnames, @@ -339,8 +617,6 @@ def transform(x): shutil.rmtree(folder, ignore_errors=True) def test_reactive_run_warmstart_gauss(): - from ultranest import ReactiveNestedSampler - from ultranest import read_file center = 0 def loglike(z): @@ -353,7 +629,6 @@ def transform(x): return x * 20000 - 10000 paramnames = ['a'] - ndim = len(paramnames) folder = tempfile.mkdtemp() np.random.seed(1) @@ -442,6 +717,62 @@ def transform(x): for name, col in zip(paramnames, result['samples'].transpose()): print('%15s : %.3f +- %.3f' % (name, col.mean(), col.std())) + +def test_run_warmstart_gauss_SLOW(): + center = None + stdev = 0.001 + + def loglike(z): + chi2 = (((z - center) / stdev)**2).sum(axis=1) + loglike.ncalls += len(z) + return -0.5 * chi2 + loglike.ncalls = 0 + + def transform(x): + return x * 20000 - 10000 + + paramnames = ['a'] + + folder = tempfile.mkdtemp() + np.random.seed(1) + ncalls = [] + try: + for i, resume in enumerate(['overwrite', 'resume-hot', 'resume-hot', 'resume-hot']): + print() + print("====== Running Gauss problem [%d] =====" % (i+1)) + print() + center = [0, 0, stdev, 1][i] + print("center:", center, "folder:", folder) + if i == 0: + sampler = ReactiveNestedSampler(paramnames, + loglike, transform=transform, + log_dir=folder, resume=resume, vectorized=True) + else: + aux_param_names, aux_loglike, aux_transform, vectorized = warmstart_from_similar_file( + os.path.join(folder, 'chains', 'weighted_post_untransformed.txt'), + paramnames, loglike=loglike, transform=transform, vectorized=True, + ) + sampler = ReactiveNestedSampler(aux_param_names, + aux_loglike, transform=aux_transform, vectorized=True) + + sampler.run(viz_callback=None) + sampler.print_results() + print("expected posterior:", center, '+-', stdev) + print(sampler.results.keys()) + print(sampler.results['posterior'].keys()) + print(sampler.results['posterior']['mean'], sampler.results['posterior']['stdev']) + print(sampler.results['weighted_samples']['upoints'], sampler.results['weighted_samples']['weights']) + assert center - stdev < sampler.results['posterior']['mean'][0] < center + stdev, (center, sampler.results['posterior']) + assert stdev * 0.8 < sampler.results['posterior']['stdev'][0] < stdev * 1.2, (center, sampler.results['posterior']) + ncalls.append(sampler.ncall) + finally: + shutil.rmtree(folder, ignore_errors=True) + print(ncalls) + + # make sure hot start is much faster + assert ncalls[1] < ncalls[0] - 800, (ncalls) + assert ncalls[2] < ncalls[0] - 800, (ncalls) + if __name__ == '__main__': #test_run_compat() #test_run_resume(dlogz=0.5) @@ -450,4 +781,7 @@ def transform(x): #test_run() #test_reactive_run_warmstart_gauss() #test_reactive_run_extraparams() - test_dlogz_reactive_run() + #test_reactive_run_resume_eggbox('hdf5') + #test_dlogz_reactive_run() + #test_plateau() + test_clustering_recursion(plot=True) diff --git a/tests/test_stepsampling.py b/tests/test_stepsampling.py index 4d4c6c92..eed40bdd 100644 --- a/tests/test_stepsampling.py +++ b/tests/test_stepsampling.py @@ -1,9 +1,19 @@ import numpy as np +import os +import pytest +import tempfile + from ultranest.mlfriends import ScalingLayer, AffineLayer, MLFriends from ultranest import ReactiveNestedSampler -from ultranest.stepsampler import RegionMHSampler, CubeMHSampler, CubeSliceSampler, RegionSliceSampler, SpeedVariableRegionSliceSampler, AHARMSampler, RegionBallSliceSampler -from ultranest.stepsampler import generate_region_random_direction, ellipsoid_bracket, crop_bracket_at_unit_cube +from ultranest.stepsampler import RegionMHSampler, CubeMHSampler, SliceSampler, CubeSliceSampler, RegionSliceSampler, SpeedVariableRegionSliceSampler, RegionBallSliceSampler, SpeedVariableGenerator +from ultranest.stepsampler import ellipsoid_bracket, crop_bracket_at_unit_cube, _inside_region +from ultranest.stepsampler import generate_random_direction, generate_cube_oriented_direction +from ultranest.stepsampler import SequentialDirectionGenerator, OrthogonalDirectionGenerator, SequentialRegionDirectionGenerator +from ultranest.stepsampler import generate_region_random_direction, generate_region_oriented_direction, generate_cube_oriented_differential_direction +from ultranest.stepsampler import generate_differential_direction, generate_partial_differential_direction, generate_mixture_random_direction + from ultranest.pathsampler import SamplingPathStepSampler +from ultranest.stepsampler import select_random_livepoint, IslandPopulationRandomLivepointSelector from numpy.testing import assert_allclose #here = os.path.dirname(__file__) @@ -38,6 +48,15 @@ def test_stepsampler_cubemh(plot=False): assert a.sum() > 1, a.sum() assert b.sum() > 1, b.sum() + # check that diagnostics fail + print("mean jump distance:", sampler.stepsampler.mean_jump_distance) + print("far enough fraction:", sampler.stepsampler.far_enough_fraction) + assert sampler.stepsampler.mean_jump_distance < 1.0, sampler.stepsampler.mean_jump_distance + assert sampler.stepsampler.far_enough_fraction < 0.5, sampler.stepsampler.far_enough_fraction + + print("Diagnostic print:") + sampler.stepsampler.print_diagnostic() + def test_stepsampler_regionmh(plot=False): np.random.seed(2) sampler = ReactiveNestedSampler(paramnames, loglike_vectorized, transform=transform, vectorized=True) @@ -49,6 +68,69 @@ def test_stepsampler_regionmh(plot=False): assert a.sum() > 1, a assert b.sum() > 1, b +def test_direction_proposals(): + ndim = 10 + np.random.seed(12) + region = make_region(ndim) + ui = region.u[0] + + scale = np.random.uniform() + vcube = generate_cube_oriented_direction(ui, region, scale) + assert (vcube != 0).sum() == 1, vcube + assert np.linalg.norm(vcube) == scale, vcube + + vcubede = generate_cube_oriented_differential_direction(ui, region, scale) + assert (vcubede != 0).sum() == 1, vcubede + assert np.linalg.norm(vcubede) > 0, vcubede + + vharm = generate_random_direction(ui, region, scale) + assert (vharm != 0).all(), vharm + + vde = generate_differential_direction(ui, region, scale) + assert (vde != 0).all(), vde + + vregionslice = generate_region_oriented_direction(ui, region, scale) + assert (vregionslice != 0).all(), vregionslice + + vmix = generate_mixture_random_direction(ui, region, scale) + assert (vmix != 0).all(), vmix + + vregionharm = generate_region_random_direction(ui, region, scale) + assert (vregionharm != 0).all(), vregionharm + + direction_generator = SequentialDirectionGenerator() + for i in range(ndim * 2): + vdir = direction_generator(ui, region, scale) + assert (vdir != 0).sum() == 1, vdir + assert np.abs(vdir[i % ndim]) > 0, vdir + + region_direction_generator = SequentialRegionDirectionGenerator() + for i in range(ndim * 2): + vdirharm = region_direction_generator(ui, region, scale) + assert (vdirharm != 0).all(), vdirharm + + vpartialde = generate_partial_differential_direction(ui, region, scale) + assert (vpartialde != 0).sum() > 1, vpartialde + assert (vpartialde != 0).sum() < ndim, vpartialde + + # test that applying OrthogonalDirectionGenerator to SequentialDirectionGenerator has no effect + ortho_direction_generator = OrthogonalDirectionGenerator(SequentialDirectionGenerator()) + for i in range(ndim * 2): + vdir = ortho_direction_generator(ui, region, scale) + assert (vdir != 0).sum() == 1, vdir + assert np.abs(vdir[i % ndim]) > 0, vdir + +def test_inside_region(): + ndim = 10 + np.random.seed(12) + region = make_region(ndim, us = np.random.uniform(0.5, 0.51, size=(400, ndim))) + i = np.random.randint(400) + ui = region.u[i] + assert _inside_region(region, ui, ui) + # corner case where a new point is close to a old case, but both are somehow outside the region + unew, uold = np.random.uniform(0.4, 0.401, size=(2, ndim)) + assert _inside_region(region, unew, uold) + def test_stepsampler_cubeslice(plot=False): np.random.seed(3) sampler = ReactiveNestedSampler(paramnames, loglike, transform=transform1) @@ -63,7 +145,7 @@ def test_stepsampler_cubeslice(plot=False): def test_stepsampler_regionslice(plot=False): np.random.seed(4) sampler = ReactiveNestedSampler(paramnames, loglike, transform=transform) - sampler.stepsampler = RegionSliceSampler(nsteps=len(paramnames)) + sampler.stepsampler = RegionSliceSampler(nsteps=2 + len(paramnames)) r = sampler.run(log_interval=50, min_num_live_points=400) sampler.print_results() a = (np.abs(r['samples'] - 0.7) < 0.1).all(axis=1) @@ -71,6 +153,38 @@ def test_stepsampler_regionslice(plot=False): assert a.sum() > 1 assert b.sum() > 1 + # check that diagnostics pass + print("mean jump distance:", sampler.stepsampler.mean_jump_distance) + print("far enough fraction:", sampler.stepsampler.far_enough_fraction) + assert sampler.stepsampler.mean_jump_distance > 1.0, sampler.stepsampler.mean_jump_distance + assert sampler.stepsampler.far_enough_fraction > 0.6, sampler.stepsampler.far_enough_fraction + + print("Diagnostic print:") + sampler.stepsampler.print_diagnostic() + +def test_SpeedVariableGenerator(): + np.random.seed(4) + ndims = [3, 10] + matrices = [ + np.array([[True, True, True], [False, True, True], [False, False, True]]), + [Ellipsis, slice(1,None), slice(2,4)] + ] + for matrix, ndim in zip(matrices, ndims): + direction_generator = SpeedVariableGenerator(matrix, generate_direction=generate_random_direction) + for i in range(10): + u0 = np.random.uniform(size=ndim) + for mask_varying in matrix: + mask = np.zeros(ndim, dtype=bool) + mask[mask_varying] = True + print("starting at u0", u0) + print("varying:", mask_varying, mask) + v = direction_generator(u0, None) + print("direction:", v) + assert_allclose(v[~mask], 0) + u1 = u0 + np.random.uniform() * v + print("new point:", u1) + assert_allclose(u1[~mask], u0[~mask]) + def test_stepsampler_variable_speed_SLOW(plot=False): matrices = [ @@ -91,7 +205,7 @@ def test_stepsampler_variable_speed_SLOW(plot=False): def make_region(ndim, us=None): if us is None: us = np.random.uniform(size=(1000, ndim)) - + if ndim > 1: transformLayer = AffineLayer() else: @@ -107,7 +221,7 @@ def test_stepsampler(plot=False): np.random.seed(6) region = make_region(len(paramnames)) Ls = loglike_vectorized(region.u) - + stepsampler = CubeMHSampler(nsteps=len(paramnames)) while True: u1, p1, L1, nc = stepsampler.__next__(region, -1e100, region.u, Ls, transform, loglike) @@ -142,13 +256,13 @@ def test_stepsampler_adapt_when_stuck(plot=False): unew, pnew, Lnew, nc = stepsampler.__next__(region, Lmin, us, Ls, transform, loglike, ndraw=10) if unew is not None: break - + new_scale = stepsampler.scale assert new_scale != old_scale assert new_scale < 0.01, (new_scale, unew) - + print('CubeSliceSampler') - stepsampler = CubeSliceSampler(nsteps=1, region_filter=True) + stepsampler = SliceSampler(nsteps=1, region_filter=True, generate_direction=generate_cube_oriented_direction) np.random.seed(23) old_scale = stepsampler.scale for j in range(100): @@ -158,41 +272,62 @@ def test_stepsampler_adapt_when_stuck(plot=False): unew, pnew, Lnew, nc = stepsampler.__next__(region, Lmin, us, Ls, transform, loglike, ndraw=10) if unew is not None: break - + new_scale = stepsampler.scale assert new_scale != old_scale assert new_scale < 0.01, (new_scale, unew) -def test_stepsampler_regionmh_adapt(plot=False): +def test_stepsampler_adapt(plot=True): np.random.seed(8) region = make_region(len(paramnames)) Ls = loglike_vectorized(region.u) - try: + with pytest.raises(ValueError): RegionMHSampler(nsteps=len(paramnames), adaptive_nsteps='Hello') - assert False, 'expected error' - except ValueError: - pass - - for sampler_class in RegionMHSampler, CubeMHSampler, CubeSliceSampler, RegionSliceSampler: - for adaptation in False, 'move-distance', 'proposal-total-distances', 'proposal-summed-distances': - print() - stepsampler = sampler_class(nsteps=len(paramnames), adaptive_nsteps=adaptation) - print(stepsampler) - stepsampler.region_changed(Ls, region) - np.random.seed(23) - old_scale = stepsampler.scale - for i in range(5): - while True: - unew, pnew, Lnew, nc = stepsampler.__next__(region, -1e100, region.u, Ls, transform, loglike) - if unew is not None: - break - new_scale = stepsampler.scale - assert new_scale != old_scale - - if adaptation: - assert stepsampler.nsteps != len(paramnames) - else: - assert stepsampler.nsteps == len(paramnames) + + with tempfile.TemporaryDirectory() as tempdir: + for sampler_class in RegionMHSampler, CubeMHSampler, CubeSliceSampler, RegionSliceSampler: + for adaptation in False, 'move-distance', 'move-distance-midway', 'proposal-total-distances', 'proposal-summed-distances': + print() + if sampler_class in (CubeMHSampler, CubeSliceSampler): + logfilename = os.path.join(tempdir, 'test-stepsampler-%s.log' % adaptation) + log = open(logfilename, 'w') + else: + logfilename = None + log = False + stepsampler = sampler_class(nsteps=len(paramnames), adaptive_nsteps=adaptation, log=log) + print(stepsampler) + stepsampler.region_changed(Ls, region) + np.random.seed(23) + old_scale = stepsampler.scale + for i in range(5): + while True: + unew, pnew, Lnew, nc = stepsampler.__next__(region, -1e100, region.u, Ls, transform, loglike) + if unew is not None: + break + new_scale = stepsampler.scale + assert new_scale != old_scale + + if adaptation: + assert stepsampler.nsteps != len(paramnames) + else: + assert stepsampler.nsteps == len(paramnames) + + if logfilename: + print(np.loadtxt(logfilename).shape) + log_nentries, log_ncolumns = np.loadtxt(logfilename).shape + assert log_nentries == 5 + assert log_ncolumns == (1 + 4 * len(unew) + 7) + + if adaptation == 'move-distance' and sampler_class == RegionSliceSampler and plot: + # test plotting + prefix = os.path.join(tempdir, 'test-stepsampler') + assert not os.path.exists(prefix + '-plot.pdf') + stepsampler.plot(prefix + '-plot.pdf') + assert os.path.exists(prefix + '-plot.pdf') + + assert not os.path.exists(prefix + '-plot-jumps.pdf') + stepsampler.plot_jump_diagnostic_histogram(prefix + '-plot-jumps.pdf') + assert os.path.exists(prefix + '-plot-jumps.pdf') def assert_point_touches_ellipsoid(ucurrent, v, t, ellipsoid_center, ellipsoid_invcov, enlarge): unext = ucurrent + v * t @@ -210,11 +345,11 @@ def test_ellipsoid_bracket(plot=False): us = us * 0.1 + 0.5 else: us = np.random.uniform(size=(2**np.random.randint(3, 10), 2)) - + if plot: import matplotlib.pyplot as plt plt.plot(us[:,0], us[:,1], 'o ', ms=2) - + transformLayer = ScalingLayer() region = MLFriends(us, transformLayer) try: @@ -239,15 +374,15 @@ def test_ellipsoid_bracket(plot=False): uleft = ucurrent + v * left uright = ucurrent + v * right - if plot: + if plot: plt.plot([uleft[0], uright[0]], [uleft[1], uright[1]], 'x-') - + plt.savefig('test_ellipsoid_bracket.pdf', bbox_inches='tight') plt.close() print("ellipsoid bracket:", left, right) assert left <= 0, left assert right >= 0, right - + assert_point_touches_ellipsoid(ucurrent, v, left, region.ellipsoid_center, region.ellipsoid_invcov, region.enlarge) assert_point_touches_ellipsoid(ucurrent, v, right, region.ellipsoid_center, region.ellipsoid_invcov, region.enlarge) @@ -260,7 +395,7 @@ def test_crop_bracket(plot=False): ellipsoid_invcov = np.array([[11.29995701, -3.17051875], [-3.17051875, 4.76837493]]) #enlarge = 1.0 #ellipsoid_inv_axes = np.array([[1.0, 0.], [0., 1]]) - + eleft, eright = ellipsoid_bracket(ucurrent, v, ellipsoid_center, ellipsoid_inv_axes, enlarge) if plot: @@ -268,7 +403,7 @@ def test_crop_bracket(plot=False): d = us - ellipsoid_center r = np.einsum('ij,jk,ik->i', d, ellipsoid_invcov, d) mask_inside = r <= enlarge - + import matplotlib.pyplot as plt plt.plot(us[mask_inside,0], us[mask_inside,1], '+', ms=2) plt.plot(ucurrent[0], ucurrent[1], 'o ', ms=2) @@ -297,73 +432,74 @@ def test_crop_bracket(plot=False): assert (ucurrent + v * left >= 0).all(), (ucurrent, v, ellipsoid_center, ellipsoid_inv_axes, enlarge) assert (ucurrent + v * right >= 0).all(), (ucurrent, v, ellipsoid_center, ellipsoid_inv_axes, enlarge) -def test_aharm_sampler(): - def loglike(theta): - return -0.5 * (((theta - 0.5)/0.01)**2).sum(axis=1) - def transform(x): - return x - - seed = 1 - Nlive = 10 - np.random.seed(seed) - us = np.random.uniform(size=(Nlive, 2)) - Ls = loglike(us) + left, right, cropleft, cropright = crop_bracket_at_unit_cube(ucurrent, -v, eleft, eright) + if plot: + plt.plot([ucurrent[0] - left * v[0], ucurrent[0] - right * v[0]], + [ucurrent[1] - left * v[1], ucurrent[1] - right * v[1]], + 's--', ms=8) + plt.savefig('test_crop_bracket_negative.pdf', bbox_inches='tight') + plt.close() + assert cropleft + assert cropright + assert (ucurrent - v * left <= 1).all(), (ucurrent, v, ellipsoid_center, ellipsoid_inv_axes, enlarge) + assert (ucurrent - v * right <= 1).all(), (ucurrent, v, ellipsoid_center, ellipsoid_inv_axes, enlarge) + assert (ucurrent - v * left >= 0).all(), (ucurrent, v, ellipsoid_center, ellipsoid_inv_axes, enlarge) + assert (ucurrent - v * right >= 0).all(), (ucurrent, v, ellipsoid_center, ellipsoid_inv_axes, enlarge) + +def test_random_point_selector(): + np.random.seed(41) + K = 10 + ndim = 2 + i1 = np.random.randint(0, K) + i2 = np.random.randint(0, K) + i3 = np.random.randint(0, K) + us = np.random.normal(size=(K, ndim)) + Ls = np.random.normal(size=K) Lmin = Ls.min() - transformLayer = ScalingLayer() - transformLayer.optimize(us, us) - region = MLFriends(us, transformLayer) - region.maxradiussq, region.enlarge = region.compute_enlargement() - region.create_ellipsoid() - assert region.inside(us).all() - nsteps = 10 - sampler = AHARMSampler(nsteps=nsteps, region_filter=True) - - nfunccalls = 0 - ncalls = 0 - while True: - u, p, L, nc = sampler.__next__(region, Lmin, us, Ls, transform, loglike) - nfunccalls += 1 - ncalls += nc - if u is not None: - break - if nfunccalls > 100 + nsteps: - assert False, ('infinite loop?', seed, nsteps, Nlive) - print("done in %d function calls, %d likelihood evals" % (nfunccalls, ncalls)) - - -def run_aharm_sampler(): - for seed in [733] + list(range(10)): - print() - print("SEED=%d" % seed) - print() - np.random.seed(seed) - nsteps = max(1, int(10**np.random.uniform(0, 3))) - Nlive = int(10**np.random.uniform(1.5, 3)) - print("Nlive=%d nsteps=%d" % (Nlive, nsteps)) - sampler = AHARMSampler(nsteps, adaptive_nsteps=False, region_filter=False) - us = np.random.uniform(0.6, 0.8, size=(4000, 2)) - Ls = loglike_vectorized(us) - i = np.argsort(Ls)[-Nlive:] - us = us[i,:] - Ls = Ls[i] + np.random.seed(41) + j1 = select_random_livepoint(us, Ls, Lmin) + j2 = select_random_livepoint(us, Ls, Lmin) + j3 = select_random_livepoint(us, Ls, Lmin) + assert i1 == j1, (i1, j1) + assert i2 == j2, (i2, j2) + assert i3 == j3, (i3, j3) + + +def test_island_point_selector(): + K = 10 + ndim = 2 + self_selector = IslandPopulationRandomLivepointSelector(1) + selector = IslandPopulationRandomLivepointSelector(5) + imbalanced_selector = IslandPopulationRandomLivepointSelector(9) + for i in range(100): + us = np.random.normal(size=(K, ndim)) + Ls = np.random.normal(size=K) Lmin = Ls.min() - - transformLayer = ScalingLayer() - transformLayer.optimize(us, us) - region = MLFriends(us, transformLayer) - region.maxradiussq, region.enlarge = region.compute_enlargement() - region.create_ellipsoid() - nfunccalls = 0 - ncalls = 0 - while True: - u, p, L, nc = sampler.__next__(region, Lmin, us, Ls, transform, loglike) - nfunccalls += 1 - ncalls += nc - if u is not None: - break - if nfunccalls > 100 + nsteps: - assert False, ('infinite loop?', seed, nsteps, Nlive) - print("done in %d function calls, %d likelihood evals" % (nfunccalls, ncalls)) + j1 = np.argmin(Ls) + j2 = selector(us, Ls, Lmin) + assert j1 == self_selector(us, Ls, Lmin) + if j1 >= 5: + assert j2 >= 5, (j1, j2) + if j1 < 5: + assert j2 < 5, (j1, j2) + if j1 == 9: + assert j1 == imbalanced_selector(us, Ls, Lmin) + else: + assert imbalanced_selector(us, Ls, Lmin) < 9 + + np.random.seed(421) + leaked = False + selector = IslandPopulationRandomLivepointSelector(5, 0.1) + for i in range(100): + j1 = np.argmin(Ls) + j2 = selector(us, Ls, Lmin) + if j1 >= 5 and j2 >= 5 or j1 < 5 and j2 < 5: + pass + else: + # leak, as expected + leaked = True + break + assert leaked if __name__ == '__main__': @@ -372,6 +508,5 @@ def run_aharm_sampler(): #test_stepsampler_de(plot=False) #test_stepsampler_cubeslice(plot=True) #test_stepsampler_regionslice(plot=True) - run_aharm_sampler() - #test_ellipsoid_bracket() + test_ellipsoid_bracket() #test_crop_bracket(plot=True) diff --git a/tests/test_utils.py b/tests/test_utils.py index 52a20017..32649788 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,65 +1,120 @@ import numpy as np import tempfile import os -from ultranest.utils import vectorize, is_affine_transform, normalised_kendall_tau_distance, make_run_dir +from ultranest.utils import vectorize, is_affine_transform, normalised_kendall_tau_distance, make_run_dir, verify_gradient +from ultranest.utils import distributed_work_chunk_size from numpy.testing import assert_allclose - +import pytest def test_vectorize(): - - def myfunc(x): - return (x**2).sum() + + def myfunc(x): + return (x**2).sum() - myvfunc = vectorize(myfunc) - - a = np.array([1.2, 2.3, 3.4]) - - assert_allclose(np.array([myfunc(a)]), myvfunc([a])) - b = np.array([[1.2, 2.3, 3.4], [1.2, 2.3, 3.4]]) - assert_allclose(np.array([myfunc(b[0]), myfunc(b[1])]), myvfunc(b)) + myvfunc = vectorize(myfunc) + + a = np.array([1.2, 2.3, 3.4]) + + assert_allclose(np.array([myfunc(a)]), myvfunc([a])) + b = np.array([[1.2, 2.3, 3.4], [1.2, 2.3, 3.4]]) + assert_allclose(np.array([myfunc(b[0]), myfunc(b[1])]), myvfunc(b)) + + class FuncClass(object): + def __call__(self, x): + return (x**2).sum() + def foo(self, x): + return x + + mycaller = FuncClass() + vectorize(mycaller) + vectorize(mycaller.foo) def test_is_affine_transform(): - na = 2**np.random.randint(1, 10) - d = 2**np.random.randint(1, 3) - a = np.random.uniform(-1, 1, size=(na, d)) - - assert is_affine_transform(a, a) - assert is_affine_transform(a, a * 2.0) - assert is_affine_transform(a, a - 1) - assert is_affine_transform(a, a * 10000 - 5000.) - assert not is_affine_transform(a, a**2) + na = 2**np.random.randint(1, 10) + d = 2**np.random.randint(1, 3) + a = np.random.uniform(-1, 1, size=(na, d)) + + assert is_affine_transform(a, a) + assert is_affine_transform(a, a * 2.0) + assert is_affine_transform(a, a - 1) + assert is_affine_transform(a, a * 10000 - 5000.) + assert not is_affine_transform(a, a**2) def test_tau(): - - assert normalised_kendall_tau_distance(np.arange(400), np.arange(400)) == 0 - assert normalised_kendall_tau_distance(np.arange(2000), np.arange(2000)) == 0 - a = np.array([1, 2, 3, 4, 5]) - b = np.array([3, 4, 1, 2, 5]) - assert normalised_kendall_tau_distance(a, b) == 0.4 - i, j = np.meshgrid(np.arange(len(a)), np.arange(len(b))) - assert normalised_kendall_tau_distance(a, b, i, j) == 0.4 - assert normalised_kendall_tau_distance(a, a, i, j) == 0 - - try: - normalised_kendall_tau_distance(np.arange(5), np.arange(10)) - raise Exception("expect error") - except AssertionError: - pass + + assert normalised_kendall_tau_distance(np.arange(400), np.arange(400)) == 0 + assert normalised_kendall_tau_distance(np.arange(2000), np.arange(2000)) == 0 + a = np.array([1, 2, 3, 4, 5]) + b = np.array([3, 4, 1, 2, 5]) + assert normalised_kendall_tau_distance(a, b) == 0.4 + i, j = np.meshgrid(np.arange(len(a)), np.arange(len(b))) + assert normalised_kendall_tau_distance(a, b, i, j) == 0.4 + assert normalised_kendall_tau_distance(a, a, i, j) == 0 + + try: + normalised_kendall_tau_distance(np.arange(5), np.arange(10)) + raise Exception("expect error") + except AssertionError: + pass + + +def test_verify_gradient(): + ndim = 4 + sigma = 0.01 + sigma = np.logspace(-1, np.log10(sigma), ndim) + width = 1 - 5 * sigma + width[width < 1e-20] = 1e-20 + centers = (np.sin(np.arange(ndim)/2.) * width + 1.) / 2. + + def loglike(theta): + return -0.5 * (((theta - centers)/sigma)**2).sum(axis=1) - 0.5 * np.log(2 * np.pi * sigma**2).sum() + + def transform(x): + return x + + def transform_loglike_gradient(u): + theta = u + like = -0.5 * (((theta - centers)/sigma)**2).sum() - 0.5 * np.log(2 * np.pi * sigma**2).sum() + grad = (theta - centers) / sigma + return u, like, grad + + def gradient(theta): + return (theta - centers) / sigma + + def wrong_gradient(theta): + return -1000 * (theta - centers) / sigma + + verify_gradient(ndim, transform, loglike, transform_loglike_gradient, combination=True, verbose=True) + verify_gradient(ndim, transform, loglike, gradient, verbose=True) + failed = False + try: + verify_gradient(ndim, transform, loglike, wrong_gradient, verbose=True) + except AssertionError: + failed = True + assert failed def test_make_log_dirs(): - import shutil - try: - filepath = tempfile.mkdtemp() - make_run_dir(filepath, max_run_num=3) - assert os.path.exists(os.path.join(filepath, 'run1')) - make_run_dir(filepath, max_run_num=3) - assert os.path.exists(os.path.join(filepath, 'run2')) - try: - make_run_dir(filepath, max_run_num=3) - assert False - except ValueError: - pass - finally: - shutil.rmtree(filepath) + import shutil + try: + filepath = tempfile.mkdtemp() + make_run_dir(filepath, max_run_num=3) + assert os.path.exists(os.path.join(filepath, 'run1')) + make_run_dir(filepath, max_run_num=3) + assert os.path.exists(os.path.join(filepath, 'run2')) + try: + make_run_dir(filepath, max_run_num=3) + assert False + except ValueError: + pass + finally: + shutil.rmtree(filepath) + +@pytest.mark.parametrize("mpi_size", [1, 4, 10, 37, 53, 100, 1000, 513]) +@pytest.mark.parametrize("num_live_points_missing", [0, 1, 4, 10, 17, 31, 100, 1000, 513]) +def test_distributed_work_chunk_size(mpi_size, num_live_points_missing): + processes = range(mpi_size) + todo = [distributed_work_chunk_size(num_live_points_missing, rank, mpi_size) for rank in processes] + assert sum(todo) == num_live_points_missing + assert max(todo) - min(todo) in {0, 1} diff --git a/tests/test_viz.py b/tests/test_viz.py index 85f8b327..972bc689 100644 --- a/tests/test_viz.py +++ b/tests/test_viz.py @@ -13,8 +13,10 @@ def wrap_single_fmt_test(vlo, vhi, fmt_expected): assert vlo < vhi plo, phi, fmts = round_parameterlimits(np.asarray([vlo]), np.asarray([vhi]), [(vlo, vhi)]) assert fmts[0] == fmt_expected, (fmts, fmt_expected) + assert len(plo) == 1, (plo) + assert len(phi) == 1, (phi) fmt = fmts[0] - assert fmt % plo != fmt % phi, (fmt, plo, phi, fmt % plo, fmt % phi) + assert fmt % plo[0] != fmt % phi[0], (fmt, plo, phi, fmt % plo, fmt % phi) def test_rounding_pos(): wrap_single_test(0.00003, 0.001, 0, 0.001) diff --git a/ultranest/__init__.py b/ultranest/__init__.py index 7d9cf73a..6242f27f 100644 --- a/ultranest/__init__.py +++ b/ultranest/__init__.py @@ -1,13 +1,9 @@ -""" -Performs nested sampling to calculate the Bayesian evidence and posterior samples -Some parts are from the Nestle library by Kyle Barbary (https://github.com/kbarbary/nestle) -Some parts are from the nnest library by Adam Moss (https://github.com/adammoss/nnest) -""" +# noqa: D400 D205 +"""UltraNets performs nested sampling to calculate the Bayesian evidence and posterior samples.""" from .integrator import NestedSampler, ReactiveNestedSampler, read_file from .utils import vectorize - __author__ = """Johannes Buchner""" __email__ = 'johannes.buchner.acad@gmx.com' -__version__ = '3.3.1' +__version__ = '4.5.0' diff --git a/ultranest/calibrator.py b/ultranest/calibrator.py new file mode 100644 index 00000000..e547e41d --- /dev/null +++ b/ultranest/calibrator.py @@ -0,0 +1,275 @@ +# noqa: D400 D205 +""" +Calibration of step sampler +--------------------------- +""" + +import os +from collections import deque + +import numpy as np + +from ultranest.integrator import ReactiveNestedSampler + + +def _last_item_from_iterator(iterator): + """Get last item from iterator. + + Parameters + ---------- + iterator: iterator + Iterator or list of elements + + Returns + ------- + element: object + last item yielded by iterator. + """ + return deque(iterator, maxlen=1).pop() + + +def _substitute_log_dir(init_args, nsteps): + """Append `nsteps` to `log_dir` argument, if set. + + Parameters + ---------- + init_args: dict + arguments passed :py:class:`ReactiveNestedSampler`, + may contain the key `'log_dir'`. + nsteps: int + number of steps + + Returns + ------- + new_init_args: dict + same as init_args, but if `'log_dir'` was set, + it now has `'-nsteps'+str(nsteps)` appended. + """ + if 'log_dir' in init_args: + args = dict(init_args) + args['log_dir'] = init_args['log_dir'] + '-nsteps%d' % nsteps + return args + return init_args + + +class ReactiveNestedCalibrator(): + """Calibrator for the number of steps in step samplers. + + The number of steps in a step sampler needs to be chosen. + A calibration recommended (e.g. https://ui.adsabs.harvard.edu/abs/2019MNRAS.483.2044H) + is to run a sequence of nested sampling runs with increasing number of steps, + and stop when log(Z) converges. + + This class automates this. See the :py:meth:`ReactiveNestedCalibrator.run` + for details. + + Usage + ----- + + Usage is designed to be a drop-in replacement for ReactiveNestedSampler. + + If your code was:: + + sampler = ReactiveNestedSampler(my_param_names, my_loglike, my_transform) + sampler.stepsampler = SliceSampler(nsteps=10, generate_direction=region_oriented_direction) + sampler.run(min_num_livepoints=400) + + You would change it to:: + + sampler = ReactiveNestedCalibrator(my_param_names, my_loglike, my_transform) + sampler.stepsampler = SliceSampler(nsteps=10, generate_direction=region_oriented_direction) + sampler.run(min_num_livepoints=400) + + The run() command will print the number of slice sampler steps + that appear safe for the inference task. + + The initial value for nsteps (e.g. in `SliceSampler(nsteps=...)`) + is overwritten by this class. + """ + + def __init__(self, + param_names, + loglike, + transform=None, + **kwargs + ): + """Initialise nested sampler calibrator. + + Parameters + ---------- + param_names: list of str + Names of the parameters. + Length gives dimensionality of the sampling problem. + loglike: function + log-likelihood function. + transform: function + parameter transform from unit cube to physical parameters. + kwargs: dict + further arguments passed to ReactiveNestedSampler. + if `log_dir` is set, then the suffix `-nsteps%d` is added for each + run, where %d is replaced with the number of steps (2, 4, 8 etc). + """ + self.init_args = dict(param_names=param_names, loglike=loglike, transform=transform, **kwargs) + self.stepsampler = None + + def run_iter(self, **kwargs): + """Run a sequence of ReactiveNestedSampler runs until convergence. + + The first run is made with the number of steps set to the number of parameters. + Each subsequent run doubles the number of steps. + Runs are made until convergence is reached. + Then this generator stops yielding results. + + Convergence is defined as three consecutive runs which + 1) are not ordered in their log(Z) results, + and 2) the consecutive log(Z) error bars must overlap. + + Parameters + ---------- + **kwargs: dict + All arguments are passed to :py:meth:`ReactiveNestedSampler.run`. + + Yields + ------ + nsteps: int + number of steps for the current run + result: dict + return value of :py:meth:`ReactiveNestedSampler.run` for the current run + """ + assert self.stepsampler is not None + self.run_args = kwargs + + # start with nsteps=d + nsteps = len(self.init_args['param_names']) + self.results = [] + self.nsteps = [] + self.relsteps = [] + + while True: + print("running with %d steps ..." % nsteps) + init_args = _substitute_log_dir(self.init_args, nsteps) + sampler = ReactiveNestedSampler(**init_args) + sampler.stepsampler = self.stepsampler.__class__( + nsteps=nsteps, generate_direction=self.stepsampler.generate_direction, + check_nsteps=self.stepsampler.check_nsteps, + adaptive_nsteps=self.stepsampler.adaptive_nsteps, + log=open(init_args['log_dir'] + '/stepsampler.log', 'w') if 'log_dir' in self.init_args else None) # noqa: SIM115 + self.sampler = sampler + result = sampler.run(**self.run_args) + print("Z=%(logz).2f +- %(logzerr).2f" % result) + if self.sampler.log_to_disk: + sampler.stepsampler.plot(os.path.join(self.sampler.logs['plots'], 'stepsampler.pdf')) + sampler.stepsampler.plot_jump_diagnostic_histogram( + os.path.join(self.sampler.logs['plots'], 'stepsampler-jumphist.pdf'), + histtype='step', bins='auto') + sampler.stepsampler.print_diagnostic() + if 'jump-distance' in sampler.stepsampler.logstat_labels and 'reference-distance' in sampler.stepsampler.logstat_labels: + i = sampler.stepsampler.logstat_labels.index('jump-distance') + j = sampler.stepsampler.logstat_labels.index('reference-distance') + jump_distances = np.array([entry[i] for entry in sampler.stepsampler.logstat]) + reference_distances = np.array([entry[j] for entry in sampler.stepsampler.logstat]) + self.relsteps.append(jump_distances / reference_distances) + # TODO: handle population step samplers + + self.results.append(result) + self.nsteps.append(nsteps) + yield nsteps, result + if len(self.results) > 2: + last_result = self.results[-2] + last_result2 = self.results[-3] + # check if they agree within the error bars + last_significant = abs(result['logz'] - last_result['logz']) > (result['logzerr']**2 + last_result['logzerr']**2)**0.5 + last2_significant = abs(last_result2['logz'] - last_result['logz']) > (last_result2['logzerr']**2 + last_result['logzerr']**2)**0.5 + # check if there is order + monotonic_increase = result['logz'] > last_result['logz'] > last_result2['logz'] + monotonic_decrease = result['logz'] < last_result['logz'] < last_result2['logz'] + if last_significant: + print("not converged: last two Z were significantly different") + elif last2_significant: + print("not yet converged: previous two Z were significantly different") + elif monotonic_increase: + print("not converged: monotonic increase in the last three Z results") + elif monotonic_decrease: + print("not converged: monotonic decrease in the last three Z results") + else: + print("converged! nsteps=%d appears safe" % nsteps) + break + + nsteps *= 2 + + def run(self, **kwargs): + """Run a sequence of ReactiveNestedSampler runs until convergence. + + The first run is made with the number of steps set to the number of parameters. + Each subsequent run doubles the number of steps. + Runs are made until convergence is reached. + Then this function returns. + + Convergence is defined as three consecutive runs which + 1) are not ordered in their log(Z) results, + and 2) the consecutive log(Z) error bars must overlap. + + Parameters + ---------- + **kwargs: dict + All arguments are passed to :py:meth:`ReactiveNestedSampler.run`. + + Returns + ------- + result: dict + return value of :py:meth:`ReactiveNestedSampler.run` for the final run + """ + _nsteps, result = _last_item_from_iterator(self.run_iter(**kwargs)) + return result + + def plot(self): + """Visualise the convergence diagnostics. + + Stores into `/plots/` folder: + * stepsampler.pdf: diagnostic of stepsampler, see :py:meth:`StepSampler.plot` + * nsteps-calibration-jumps.pdf: distribution of relative jump distance + * nsteps-calibration.pdf: evolution of ln(Z) with nsteps + """ + self.sampler.stepsampler.plot(os.path.join(self.sampler.logs['plots'], 'stepsampler.pdf')) + + # plot U-test convergence run length (at 4 sigma) (or niter) vs nsteps + # plot step > reference fraction vs nsteps + calibration_results = [] + + import matplotlib.pyplot as plt + plt.figure("jump-distance") + print("jump distance diagnostic:") + for nsteps, relsteps, result in zip(self.nsteps, self.relsteps, self.results): + calibration_results.append([ + nsteps, result['logz'], result['logzerr'], + min(result['niter'], result['insertion_order_MWW_test']['independent_iterations']), + result['insertion_order_MWW_test']['converged'] * 1, + np.nanmean(relsteps > 1)]) + plt.hist(np.log10(relsteps + 1e-10), histtype='step', bins='auto', label=nsteps) + print(' %-4d: %.2f%% avg:%.2f' % (nsteps, np.nanmean(relsteps > 1) * 100.0, np.exp(np.nanmean(np.log(relsteps))))) + if 'log_dir' in self.init_args: + np.savetxt( + self.init_args['log_dir'] + 'calibration.csv', + calibration_results, delimiter=',', comments='', + header='nsteps,logz,logzerr,maxUrun,Uconverged,stepfrac', + fmt='%d,%.3f,%.3f,%d,%d,%.5f') + plt.xlabel('$log_{10}$(relative step distance)') + plt.ylabel('Frequency') + plt.legend(title='nsteps', loc='best') + if self.sampler.log_to_disk: + plt.savefig(os.path.join(self.sampler.logs['plots'], 'nsteps-calibration-jumps.pdf'), bbox_inches='tight') + plt.close() + + plt.figure("logz") + plt.errorbar( + x=self.nsteps, + y=[result['logz'] for result in self.results], + yerr=[result['logzerr'] for result in self.results], + ) + plt.title('Step sampler calibration') + plt.xlabel('Number of steps') + plt.ylabel('ln(Z)') + if self.sampler.log_to_disk: + plt.savefig(os.path.join(self.sampler.logs['plots'], 'nsteps-calibration.pdf'), bbox_inches='tight') + plt.close() + self.sampler.logger.debug('Making nsteps calibration plot ... done') diff --git a/ultranest/dychmc.py b/ultranest/dychmc.py index fc988e39..f9e25f8c 100644 --- a/ultranest/dychmc.py +++ b/ultranest/dychmc.py @@ -3,25 +3,33 @@ Uses gradient to reflect at nested sampling boundaries. """ -from __future__ import print_function, division -import numpy as np +from __future__ import division, print_function + import matplotlib.pyplot as plt +import numpy as np + def stop_criterion(thetaminus, thetaplus, rminus, rplus): - """ Compute the stop condition in the main loop - dot(dtheta, rminus) >= 0 & dot(dtheta, rplus >= 0) + """Compute the stop condition in the main loop - INPUTS - ------ - thetaminus, thetaplus: ndarray[float, ndim=1] - under and above position - rminus, rplus: ndarray[float, ndim=1] - under and above momentum + computes: + `dot(dtheta, rminus) >= 0 & dot(dtheta, rplus >= 0)` - OUTPUTS + Parameters + ------ + thetaminus: ndarray[float, ndim=1] + under position + thetaplus: ndarray[float, ndim=1] + above position + rminus: ndarray[float, ndim=1] + under momentum + rplus: ndarray[float, ndim=1] + above momentum + + Returns ------- criterion: bool - return if the condition is valid + whether the condition is valid """ dtheta = thetaplus - thetaminus #print("stop?", dtheta, rminus, rplus, np.dot(dtheta, rminus.T), np.dot(dtheta, rplus.T)) @@ -29,7 +37,7 @@ def stop_criterion(thetaminus, thetaplus, rminus, rplus): def step_or_reflect(theta, v, epsilon, transform, loglike, gradient, Lmin): - """Make a step from theta towards v with stepsize epsilon. """ + """Make a step from `theta` towards `v` with stepsize `epsilon`. """ # step in position: thetaprime = theta + epsilon * v # check if still inside @@ -154,7 +162,7 @@ def build_tree(theta, v, direction, j, epsilon, transform, loglike, gradient, Lm return thetaminus, vminus, pminus, thetaplus, vplus, pplus, thetaprime, vprime, pprime, logpprime, sprime, can_continue, alphaprime, nalphaprime, nreflectprime def tree_sample(theta, p, logL, v, epsilon, transform, loglike, gradient, Lmin, maxheight=np.inf): - """Build NUTS-like tree of sampling path from theta towards p with stepsize epsilon.""" + """Build NUTS-like tree of sampling path from `theta` towards `p` with stepsize `epsilon`.""" # initialize the tree thetaminus = theta thetaplus = theta @@ -225,7 +233,7 @@ def tree_sample(theta, p, logL, v, epsilon, transform, loglike, gradient, Lmin, return alpha, nreflect, nalpha, theta, p, logp, j def generate_uniform_direction(d, massmatrix): - """ draw unit direction vector according to mass matrix """ + """Draw unit direction vector according to mass matrix.""" momentum = np.random.multivariate_normal(np.zeros(d), np.dot(massmatrix, np.eye(d))) momentum /= (momentum**2).sum()**0.5 return momentum @@ -241,42 +249,30 @@ class DynamicCHMCSampler(object): A No-U-turn criterion and randomized doubling of forward or backward steps is used to avoid repeating circular trajectories. Because of this, the number of steps is dynamic. - """ - - def __init__(self, ndim, nsteps, transform, loglike, gradient, adaptive_nsteps=False, delta=0.9, nudge=1.04): - """Initialise sampler. - Parameters - ----------- - nsteps: int - number of accepted steps until the sample is considered independent. - - adaptive_nsteps: False, 'proposal-distance', 'move-distance' - if not false, allow earlier termination than nsteps. - The 'proposal-distance' strategy stops when the sum of - all proposed vectors exceeds the mean distance - between pairs of live points. - As distance, the Mahalanobis distance is used. - The 'move-distance' strategy stops when the distance between - start point and current position exceeds the mean distance - between pairs of live points. - - transform: function - called with unit cube position vector u, returns - transformed parameter vector p. - loglike: function - called with transformed parameters p, returns loglikelihood - gradient: function - called with unit cube position vector u, returns - gradient (dlogL/du, not just dlogL/dp) + Parameters + ----------- + nsteps: int + number of accepted steps until the sample is considered independent. + adaptive_nsteps: False, 'proposal-distance', 'move-distance' + if not false, allow earlier termination than nsteps. + The 'proposal-distance' strategy stops when the sum of + all proposed vectors exceeds the mean distance + between pairs of live points. + As distance, the Mahalanobis distance is used. + The 'move-distance' strategy stops when the distance between + start point and current position exceeds the mean distance + between pairs of live points. + delta: float + step size + nudge: float + change in step size, must be >1. + """ - """ + def __init__(self, scale, nsteps, adaptive_nsteps=False, delta=0.9, nudge=1.04): self.history = [] self.nsteps = nsteps - self.scale = 0.1 * ndim**0.5 - self.transform = transform - self.loglike = loglike - self.gradient = gradient + self.scale = scale self.nudge = nudge self.nsteps_nudge = 1.01 adaptive_nsteps_options = (False, 'proposal-total-distances-NN', 'proposal-summed-distances-NN', @@ -298,6 +294,9 @@ def __init__(self, ndim, nsteps, transform, loglike, gradient, adaptive_nsteps=F self.logstat_labels += ['jump-distance', 'reference-distance'] self.logstat_trajectory = [] + def set_gradient(self, gradient): + self.gradient = gradient + def __str__(self): """Get string representation.""" if not self.adaptive_nsteps: @@ -329,7 +328,7 @@ def plot(self, filename): header=','.join(self.logstat_labels), delimiter=',') plt.close() - def __next__(self, region, Lmin, us, Ls, transform, loglike, ndraw=40, plot=False): + def __next__(self, region, Lmin, us, Ls, transform, loglike, ndraw=40, plot=False, tregion=None): """Get a new point. Parameters @@ -352,6 +351,9 @@ def __next__(self, region, Lmin, us, Ls, transform, loglike, ndraw=40, plot=Fals whether to produce debug plots. """ + self.transform = transform + self.loglike = loglike + i = np.random.randint(len(Ls)) #print("starting from live point %d" % i) self.starti = i @@ -485,7 +487,8 @@ def adjust_stepsize(self): self.logstat_trajectory = [] if len(self.logstat) % N == 0: - print("updating step size: %.4f %.4f %.1f --> %g " % (alphamean, reflectmean, treeheightmean, self.scale)) + print("updating step size: alpha=%.4f refl=%.4f treeheight=%.1f --> scale=%g " % ( + alphamean, reflectmean, treeheightmean, self.scale)) def region_changed(self, Ls, region): """React to change of region. """ diff --git a/ultranest/dyhmc.py b/ultranest/dyhmc.py index 2d3ff035..f1e19679 100644 --- a/ultranest/dyhmc.py +++ b/ultranest/dyhmc.py @@ -4,8 +4,8 @@ A helper surface is created using the live points. """ -import numpy as np import matplotlib.pyplot as plt +import numpy as np import scipy.special import scipy.stats @@ -14,17 +14,21 @@ def stop_criterion(thetaminus, thetaplus, rminus, rplus): """ Compute the stop condition in the main loop dot(dtheta, rminus) >= 0 & dot(dtheta, rplus >= 0) - INPUTS + Parameters ------ - thetaminus, thetaplus: ndarray[float, ndim=1] - under and above position - rminus, rplus: ndarray[float, ndim=1] - under and above momentum - - OUTPUTS + thetaminus: ndarray[float, ndim=1] + under position + thetaplus: ndarray[float, ndim=1] + above position + rminus: ndarray[float, ndim=1] + under momentum + rplus: ndarray[float, ndim=1] + above momentum + + Returns ------- criterion: bool - return if the condition is valid + whether the condition is valid """ dtheta = thetaplus - thetaminus return (np.dot(dtheta, rminus.T) >= 0) & (np.dot(dtheta, rplus.T) >= 0) @@ -197,7 +201,6 @@ def find_beta_params_dynamic(d, u10): """ Define auxiliary distribution taking into account kinetic energy of a d-dimensional HMC. Make exp(-d/2) quantile to be at u=0.1, and 95% quantile at u=0.5. """ - del d u50 = (u10 + 1) / 2. @@ -464,7 +467,7 @@ def plot(self, filename): plt.savefig(filename, bbox_inches='tight') plt.close() - def __next__(self, region, Lmin, us, Ls, transform, loglike, ndraw=40, plot=False): + def __next__(self, region, Lmin, us, Ls, transform, loglike, ndraw=40, plot=False, tregion=None): """Get a new point. Parameters diff --git a/ultranest/flatnuts.py b/ultranest/flatnuts.py index cb64eaea..2d5684c7 100644 --- a/ultranest/flatnuts.py +++ b/ultranest/flatnuts.py @@ -1,8 +1,9 @@ """ -FLATNUTS -========= +FLATNUTS is a implementation of No-U-turn sampler +for nested sampling assuming a flat prior space (hyper-cube u-space). -Experimental. +This is highly experimental. It is similar to NoGUTS and suffers from +the same stability problems. Directional sampling within regions. @@ -50,9 +51,10 @@ """ +import matplotlib.pyplot as plt import numpy as np from numpy.linalg import norm -import matplotlib.pyplot as plt + from .samplingpath import angle, extrapolate_ahead @@ -715,10 +717,21 @@ def next(self, Llast=None): def sample_chain_point(self, a, b): """ - Gets a point on the track between a and b (inclusive) - returns tuple ((point coordinates, likelihood), is_independent) - where is_independent is always True + Gets a point on the track between a and b (inclusive). + Parameters + ---------- + a: array + starting point + b: array + end point + + Returns + -------- + newpoint: tuple + tuple of point_coordinates and loglikelihood + is_independent: bool + always True """ if self.plot: for i in range(a, b+1): @@ -767,4 +780,3 @@ def build_tree(self, startstate, j, rwd): # additional criterion: start and end velocities must point in opposite directions stop = stopa or stopb or angle(xright-xleft, vleft) <= 0 or angle(xright-xleft, vright) <= 0 or angle(vleft, vright) <= 0 return (ileft, xleft, vleft), (iright, xright, vright), (ileft,iright), stop - diff --git a/ultranest/hotstart.py b/ultranest/hotstart.py index fcdd582d..a7e83e6c 100644 --- a/ultranest/hotstart.py +++ b/ultranest/hotstart.py @@ -1,8 +1,18 @@ -"""Hot start helper functions.""" +# noqa: D400 D205 +""" +Warm start +---------- + +Helper functions for deforming the parameter space to enable +a more efficient sampling. + +Based on ideas from Petrosyan & Handley (2022, https://arxiv.org/abs/2212.01760). + +""" import numpy as np -import scipy.stats -from .utils import vectorize, resample_equal + +from .utils import resample_equal, vectorize def get_auxiliary_problem(loglike, transform, ctr, invcov, enlargement_factor, df=1): @@ -39,7 +49,6 @@ def get_auxiliary_problem(loglike, transform, ctr, invcov, enlargement_factor, d enlargement_factor: float Factor by which the scale of the auxiliary distribution is enlarged in all dimensions. - For Gaussian-like posteriors, sqrt(ndim) seems to work, Heavier tailed or non-elliptical distributions may need larger factors. df: float @@ -47,7 +56,7 @@ def get_auxiliary_problem(loglike, transform, ctr, invcov, enlargement_factor, d The default is recommended. For truly gaussian posteriors, the student-t can be made more gaussian (by df>=30) for accelation. - Returns: + Returns --------- aux_loglike: function auxiliary loglikelihood function. @@ -57,6 +66,7 @@ def get_auxiliary_problem(loglike, transform, ctr, invcov, enlargement_factor, d The first d return coordinates are identical to what ``transform`` would return. The final coordinate is the correction weight. """ + import scipy.stats ndim, = ctr.shape assert invcov.shape == (ndim, ndim) assert df >= 1, ('Degrees of freedom must be above 1', df) @@ -78,8 +88,8 @@ def aux_loglikelihood(u): if not (x > 0).all() or not (x < 1).all(): return -1e300 # undo the effect of the auxiliary distribution - loglike = rv_auxiliary1d.logpdf(coords).sum() - return loglike(transform(x)) - loglike + loglike_total = rv_auxiliary1d.logpdf(coords).sum() + return loglike(transform(x)) - loglike_total def aux_aftertransform(u): return transform(aux_rotator(rv_auxiliary1d.ppf(u))) @@ -122,7 +132,7 @@ def get_extended_auxiliary_problem(loglike, transform, ctr, invcov, enlargement_ The default is recommended. For truly gaussian posteriors, the student-t can be made more gaussian (by df>=30) for accelation. - Returns: + Returns --------- aux_loglike: function auxiliary loglikelihood function. Takes d + 1 parameters (see below). @@ -133,6 +143,7 @@ def get_extended_auxiliary_problem(loglike, transform, ctr, invcov, enlargement_ The first d return coordinates are identical to what ``transform`` would return. The final coordinate is the correction weight. """ + import scipy.stats ndim, = ctr.shape assert invcov.shape == (ndim, ndim) assert df >= 1, ('Degrees of freedom must be above 1', df) @@ -168,6 +179,290 @@ def aux_loglikelihood(x): return aux_loglikelihood, aux_transform +def get_extended_auxiliary_independent_problem(loglike, transform, ctr, err, df=1): + """Return a new loglike and transform based on an auxiliary distribution. + + Given a likelihood and prior transform, and information about + the (expected) posterior peak, generates a auxiliary + likelihood and prior transform that is identical but + requires fewer nested sampling iterations. + + This is achieved by deforming the prior space, and undoing that + transformation by correction weights in the likelihood. + + The auxiliary distribution used for transformation/weighting is + a independent Student-t distribution for each parameter. + + Usage:: + + aux_loglikelihood, aux_transform = get_auxiliary_problem(loglike, transform, ctr, invcov, enlargement_factor, df=1) + aux_sampler = ReactiveNestedSampler(parameters, aux_loglikelihood, transform=aux_transform, derived_param_names=['logweight']) + aux_results = aux_sampler.run() + posterior_samples = aux_results['samples'][:,-1] + + Parameters + ------------ + loglike: function + original likelihood function + transform: function + original prior transform function + ctr: array + Posterior center (in u-space). + err: array + Standard deviation around the posterior center (in u-space). + df: float + Number of degrees of freedom of the auxiliary student-t distribution. + The default is recommended. For truly gaussian posteriors, + the student-t can be made more gaussian (by df>=30) for accelation. + + Returns + --------- + aux_loglike: function + auxiliary loglikelihood function. + aux_transform: function + auxiliary transform function. + Takes d u-space coordinates, and returns d + 1 p-space parameters. + The first d return coordinates are identical to what ``transform`` would return. + The final coordinate is the log of the correction weight. + """ + import scipy.stats + ndim, = np.shape(ctr) + assert np.shape(err) == (ndim,) + assert df >= 1, ('Degrees of freedom must be above 1', df) + + rv_aux = scipy.stats.t(df, ctr, err) + # handle the case where the aux distribution extends beyond the unit cube + aux_lo = rv_aux.cdf(0) + aux_hi = rv_aux.cdf(1) + aux_w = aux_hi - aux_lo + weight_ref = rv_aux.logpdf(ctr).sum() + + def aux_transform(u): + # get uniform gauss/t distributed values: + x = rv_aux.ppf(u * aux_w + aux_lo) + weight = -rv_aux.logpdf(x).sum() + weight_ref + return np.append(transform(x), weight) + + def aux_loglikelihood(x): + x_actual = x[:-1] + weight = x[-1] + if -1e100 < weight < 1e100: + return loglike(x_actual) + weight - weight_ref + else: + return -1e300 + + return aux_loglikelihood, aux_transform + + +def compute_quantile_intervals(steps, upoints, uweights): + """Compute lower and upper axis quantiles. + + Parameters + ------------ + steps: array + list of quantiles q to compute. + upoints: array + samples, with dimensions (N, d) + uweights: array + sample weights + + Returns + --------- + ulo: array + list of lower quantiles (at q), one entry for each dimension d. + uhi: array + list of upper quantiles (at 1-q), one entry for each dimension d. + """ + ndim = upoints.shape[1] + nboxes = len(steps) + ulos = np.empty((nboxes + 1, ndim)) + uhis = np.empty((nboxes + 1, ndim)) + for j, pthresh in enumerate(steps): + for i, ui in enumerate(upoints.transpose()): + order = np.argsort(ui) + c = np.cumsum(uweights[order]) + usel = ui[order][np.logical_and(c >= pthresh, c <= 1 - pthresh)] + ulos[j,i] = usel.min() + uhis[j,i] = usel.max() + ulos[-1] = 0 + uhis[-1] = 1 + return ulos, uhis + + +def compute_quantile_intervals_refined(steps, upoints, uweights, logsteps_max=20): + """Compute lower and upper axis quantiles. + + Parameters + ------------ + steps: array + list of quantiles q to compute, with dimensions + upoints: array + samples, with dimensions (N, d) + uweights: array + sample weights. N entries. + logsteps_max: int + number of intermediate steps to inject between largest quantiles interval and full unit cube + + Returns + --------- + ulo: array + list of lower quantiles (at `q`), of shape (M, d), one entry per quantile and dimension d. + uhi: array + list of upper quantiles (at 1-`q`), of shape (M, d), one entry per quantile and dimension d. + uinterpspace: array + list of steps (length of `steps` plus `logsteps_max` long) + """ + nboxes = len(steps) + ulos_orig, uhis_orig = compute_quantile_intervals(steps, upoints, uweights) + assert len(ulos_orig) == nboxes + 1 + assert len(uhis_orig) == nboxes + 1 + + smallest_axis_width = np.min(uhis_orig[-2,:] - ulos_orig[-2,:]) + logsteps = min(logsteps_max, int(np.ceil(-np.log10(max(1e-100, smallest_axis_width))))) + + weights = np.logspace(-logsteps, 0, logsteps + 1).reshape((-1, 1)) + # print("logspace:", weights, logsteps) + assert len(weights) == logsteps + 1, (weights.shape, logsteps) + # print("quantiles:", ulos_orig, uhis_orig) + ulos_new = ulos_orig[nboxes - 1, :].reshape((1, -1)) * (1 - weights) + 0 * weights + uhis_new = uhis_orig[nboxes - 1, :].reshape((1, -1)) * (1 - weights) + 1 * weights + + # print("additional quantiles:", ulos_new, uhis_new) + + ulos = np.vstack((ulos_orig[:-1,:], ulos_new)) + uhis = np.vstack((uhis_orig[:-1,:], uhis_new)) + # print("combined quantiles:", ulos, uhis) + assert (ulos[-1,:] == 0).all() + assert (uhis[-1,:] == 1).all() + + uinterpspace = np.ones(nboxes + logsteps + 1) + uinterpspace[:nboxes + 1] = np.linspace(0, 1, nboxes + 1) + assert 0 < uinterpspace[nboxes - 1] < 1, uinterpspace[nboxes] + uinterpspace[nboxes:] = np.linspace(uinterpspace[nboxes - 1], 1, logsteps + 2)[1:] + + return ulos, uhis, uinterpspace + + +def get_auxiliary_contbox_parameterization( + param_names, loglike, transform, upoints, uweights, vectorized=False, +): + """Return a new loglike and transform based on an auxiliary distribution. + + Given a likelihood and prior transform, and information about + the (expected) posterior peak, generates a auxiliary + likelihood and prior transform that is identical but + requires fewer nested sampling iterations. + + This is achieved by deforming the prior space, and undoing that + transformation by correction weights in the likelihood. + A additional parameter, "aux_logweight", is added at the end, + which contains the correction weight. You can ignore it. + + The auxiliary distribution used for transformation/weighting is + factorized. Each axis considers the ECDF of the auxiliary samples, + and segments it into quantile segments. Within each segment, + the parameter edges in u-space are linearly interpolated. + To see the interpolation quantiles for each axis, use:: + + steps = 10**-(1.0 * np.arange(1, 8, 2)) + ulos, uhis, uinterpspace = compute_quantile_intervals_refined(steps, upoints, uweights) + + Parameters + ------------ + param_names: list + parameter names + loglike: function + original likelihood function + transform: function + original prior transform function + upoints: array + Posterior samples (in u-space). + uweights: array + Weights of samples (needs to sum of 1) + vectorized: bool + whether the loglike & transform functions are vectorized + + Returns + --------- + aux_param_names: list + new parameter names (`param_names`) plus additional 'aux_logweight' + aux_loglike: function + auxiliary loglikelihood function. + aux_transform: function + auxiliary transform function. + Takes d u-space coordinates, and returns d + 1 p-space parameters. + The first d return coordinates are identical to what ``transform`` would return. + The final coordinate is the log of the correction weight. + vectorized: bool + whether the returned functions are vectorized + + Usage + ------ + :: + + aux_loglikelihood, aux_transform = get_auxiliary_contbox_parameterization( + loglike, transform, auxiliary_usamples) + aux_sampler = ReactiveNestedSampler(parameters, aux_loglikelihood, transform=aux_transform, derived_param_names=['logweight']) + aux_results = aux_sampler.run() + posterior_samples = aux_results['samples'][:,-1] + + """ + upoints = np.asarray(upoints) + assert upoints.ndim == 2, ('expected 2d array for upoints, got shape: %s' % upoints.shape) + mask = np.logical_and(upoints > 0, upoints < 1).all(axis=1) + assert np.all(mask), ( + 'upoints must be between 0 and 1, have:', upoints[~mask,:]) + steps = 10**-(1.0 * np.arange(1, 8, 2)) + nsamples, ndim = upoints.shape + assert nsamples > 10 + ulos, uhis, uinterpspace = compute_quantile_intervals_refined(steps, upoints, uweights) + + aux_param_names = param_names + ['aux_logweight'] + + def aux_transform(u): + ndim2, = u.shape + assert ndim2 == ndim + 1 + umod = np.empty(ndim) + log_aux_volume_factors = 0 + for i in range(ndim): + ulo_here = np.interp(u[-1], uinterpspace, ulos[:,i]) + uhi_here = np.interp(u[-1], uinterpspace, uhis[:,i]) + umod[i] = ulo_here + (uhi_here - ulo_here) * u[i] + log_aux_volume_factors += np.log(uhi_here - ulo_here) + return np.append(transform(umod), log_aux_volume_factors) + + def aux_transform_vectorized(u): + nsamples, ndim2 = u.shape + assert ndim2 == ndim + 1 + umod = np.empty((nsamples, ndim2 - 1)) + log_aux_volume_factors = np.zeros((nsamples, 1)) + for i in range(ndim): + ulo_here = np.interp(u[:,-1], uinterpspace, ulos[:,i]) + uhi_here = np.interp(u[:,-1], uinterpspace, uhis[:,i]) + umod[:,i] = ulo_here + (uhi_here - ulo_here) * u[:,i] + log_aux_volume_factors[:,0] += np.log(uhi_here - ulo_here) + return np.hstack((transform(umod), log_aux_volume_factors)) + + def aux_loglikelihood(x): + x_actual = x[:-1] + logl = loglike(x_actual) + aux_logweight = x[-1] + # downweight if we are in the auxiliary distribution + return logl + aux_logweight + + def aux_loglikelihood_vectorized(x): + x_actual = x[:,:-1] + logl = loglike(x_actual) + aux_logweight = x[:,-1] + # downweight if we are in the auxiliary distribution + return logl + aux_logweight + + if vectorized: + return aux_param_names, aux_loglikelihood_vectorized, aux_transform_vectorized, vectorized + else: + return aux_param_names, aux_loglikelihood, aux_transform, vectorized + + def reuse_samples( param_names, loglike, points, logl, logw=None, logz=0.0, logzerr=0.0, upoints=None, @@ -202,7 +497,7 @@ def reuse_samples( log_weight_threshold: float Lowest log-weight to consider - Returns: + Returns --------- results: dict All information of the run. Important keys: diff --git a/ultranest/integrator.py b/ultranest/integrator.py index d9ee38b3..4f9ecaf6 100644 --- a/ultranest/integrator.py +++ b/ultranest/integrator.py @@ -1,31 +1,49 @@ -"""Ultranest calculates the Bayesian evidence and posterior samples of arbitrary models.""" +# noqa: D400 D205 +""" +Nested sampling integrators +--------------------------- + +This module provides the high-level class :py:class:`ReactiveNestedSampler`, +for calculating the Bayesian evidence and posterior samples of arbitrary models. + +""" # Some parts are from the Nestle library by Kyle Barbary (https://github.com/kbarbary/nestle) # Some parts are from the nnest library by Adam Moss (https://github.com/adammoss/nnest) -from __future__ import print_function, division +from __future__ import division, print_function -import os -import sys import csv import json import operator +import os +import sys import time import warnings -from numpy import log, exp, logaddexp import numpy as np - -from .utils import create_logger, make_run_dir, resample_equal, vol_prefactor, vectorize, listify as _listify -from .utils import is_affine_transform, normalised_kendall_tau_distance -from ultranest.mlfriends import MLFriends, AffineLayer, ScalingLayer, find_nearby, WrappingEllipsoid, RobustEllipsoidRegion -from .store import HDF5PointStore, TextPointStore, NullPointStore -from .viz import get_default_viz_callback, nicelogger +from numpy import exp, log, logaddexp + +from .hotstart import get_auxiliary_contbox_parameterization +from .mlfriends import (AffineLayer, LocalAffineLayer, MLFriends, + RobustEllipsoidRegion, ScalingLayer, WrappingEllipsoid, + find_nearby) +from .netiter import (BreadthFirstIterator, MultiCounter, PointPile, + SingleCounter, TreeNode, combine_results, + count_tree_between, dump_tree, find_nodes_before, + logz_sequence) from .ordertest import UniformOrderAccumulator -from .netiter import PointPile, SingleCounter, MultiCounter, BreadthFirstIterator, TreeNode, count_tree_between, find_nodes_before, logz_sequence -from .netiter import dump_tree, combine_results +from .store import HDF5PointStore, NullPointStore, TextPointStore +from .utils import (create_logger, distributed_work_chunk_size, + is_affine_transform) +from .utils import listify as _listify +from .utils import (make_run_dir, normalised_kendall_tau_distance, + resample_equal, vectorize, vol_prefactor) +from .viz import get_default_viz_callback + +__all__ = ['ReactiveNestedSampler', 'NestedSampler', 'read_file', 'warmstart_from_similar_file'] -__all__ = ['ReactiveNestedSampler', 'NestedSampler', 'read_file'] +int_t = np.int64 def _get_cumsum_range(pi, dp): @@ -38,7 +56,7 @@ def _get_cumsum_range(pi, dp): dp: float Quantile (between 0 and 0.5). - Returns: + Returns --------- index_lo: int Index of the item corresponding to quantile ``dp``. @@ -46,9 +64,12 @@ def _get_cumsum_range(pi, dp): Index of the item corresponding to quantile ``1-dp``. """ ci = pi.cumsum() - ilo = np.where(ci > dp)[0] + # this builds a conservatively narrow interval + # find first index where the cumulative is surely above + ilo, = np.where(ci >= dp) ilo = ilo[0] if len(ilo) > 0 else 0 - ihi = np.where(ci < 1. - dp)[0] + # find last index where the cumulative is surely below + ihi, = np.where(ci <= 1. - dp) ihi = ihi[-1] if len(ihi) > 0 else -1 return ilo, ihi @@ -63,7 +84,7 @@ def _sequentialize_width_sequence(minimal_widths, min_width): min_width: int Minimum width everywhere. - Returns: + Returns --------- Lsequence: list of (L, width) A sequence of L points and the expected tree width at and above it. @@ -145,10 +166,6 @@ def resume_from_similar_file( new likelihood function transform: function new transform function - verbose: bool - show progress - ndraw: int - set to >1 if functions can take advantage of vectorized computations max_tau: float Allowed dissimilarity in the live point ordering, quantified as normalised Kendall tau distance. @@ -157,9 +174,13 @@ def resume_from_similar_file( when the live point order differs. Near 1 are completely different live point orderings. Values in between permit mild disorder. + verbose: bool + show progress + ndraw: int + set to >1 if functions can take advantage of vectorized computations Returns - ---------- + ------- sequence: dict contains arrays storing for each iteration estimates of: @@ -258,7 +279,7 @@ def pop(Lmin): logls_new = [] j = 0 - for Lmin, active_values, children in batch: + for _Lmin, active_values, children in batch: next_node2 = explorer2.next_node() rootid2, node2, (active_nodes2, _, active_values2, _) = next_node2 @@ -303,7 +324,7 @@ def pop(Lmin): last_good_like = last_good_like * epsilon break - for u, v, logl_old in children: + for u, v, _logl_old in children: logl_new = logls_new[j] j += 1 @@ -394,7 +415,7 @@ def _update_region_bootstrap(region, nbootstraps, minvol=0., comm=None, mpi_size return r, f -class NestedSampler(object): +class NestedSampler: """Simple Nested sampler for reference.""" def __init__(self, @@ -436,9 +457,11 @@ def __init__(self, vectorized: bool If true, loglike and transform function can receive arrays of points. + run_num: int + unique run number. If None, will be automatically incremented. """ - self.paramnames = param_names + self.paramnames = list(param_names) x_dim = len(self.paramnames) self.num_live_points = num_live_points self.sampler = 'nested' @@ -470,7 +493,7 @@ def __init__(self, assert p.shape == (2, self.num_params), ("Error in transform function: returned shape is %s, expected %s" % (p.shape, (2, self.num_params))) logl = loglike(p) assert np.logical_and(u > 0, u < 1).all(), ("Error in transform function: u was modified!") - assert logl.shape == (2,), ("Error in loglikelihood function: returned shape is %s, expected %s" % (p.shape, (2, self.num_params))) + assert np.shape(logl) == (2,), ("Error in loglikelihood function: returned shape is %s, expected %s" % (p.shape, (2, self.num_params))) assert np.isfinite(logl).all(), ("Error in loglikelihood function: returned non-finite number: %s for input u=%s p=%s" % (logl, u, p)) def safe_loglike(x): @@ -531,17 +554,24 @@ def run( Parameters ---------- - update_interval_iter: + update_interval_iter: None | int Update region after this many iterations. - update_interval_ncall: + update_interval_ncall: None | int Update region after update_interval_ncall likelihood calls. - log_interval: + log_interval: None | int Update stdout status line every log_interval iterations - dlogz: + dlogz: float Target evidence uncertainty. - max_iters: + max_iters: None | int maximum number of integration iterations. + Returns + ------- + results: dict + dictionary with posterior *samples* and original *weighted_samples*, + number of likelihood calls *ncall*, + number of nested sampling iterations *niter*, evidence + estimate *logz* and uncertainty *logzerr*. """ if update_interval_ncall is None: update_interval_ncall = max(1, round(self.num_live_points)) @@ -567,7 +597,7 @@ def run( if self.log: # try to resume: self.logger.info('Resuming...') - for i in range(self.num_live_points): + for _i in range(self.num_live_points): _, row = self.pointstore.pop(-np.inf) if row is not None: prev_logl.append(row[1]) @@ -876,7 +906,7 @@ def run( return self.results - def print_results(self, logZ=True, posterior=True): + def print_results(self): """Give summary of marginal likelihood and parameters.""" print() print('logZ = %(logz).3f +- %(logzerr).3f' % self.results) @@ -897,8 +927,8 @@ def print_results(self, logZ=True, posterior=True): def plot(self): """Make corner plot.""" if self.log_to_disk: - import matplotlib.pyplot as plt import corner + import matplotlib.pyplot as plt data = np.array(self.results['weighted_samples']['points']) weights = np.array(self.results['weighted_samples']['weights']) cumsumweights = np.cumsum(weights) @@ -913,7 +943,92 @@ def plot(self): plt.close() -class ReactiveNestedSampler(object): +def warmstart_from_similar_file( + usample_filename, + param_names, + loglike, + transform, + vectorized=False, + min_num_samples=50 +): + """Warmstart from a previous run. + + Usage:: + + aux_paramnames, aux_log_likelihood, aux_prior_transform, vectorized = warmstart_from_similar_file( + 'model1/chains/weighted_post_untransformed.txt', parameters, log_likelihood_with_background, prior_transform) + + aux_sampler = ReactiveNestedSampler(aux_paramnames, aux_log_likelihood, transform=aux_prior_transform,vectorized=vectorized) + aux_sampler.run() + posterior_samples = aux_results['samples'][:,-1] + + See :py:func:`ultranest.hotstart.get_auxiliary_contbox_parameterization` + for more information. + + The remaining parameters have the same meaning as in :py:class:`ReactiveNestedSampler`. + + Parameters + ------------ + usample_filename: str + 'directory/chains/weighted_post_untransformed.txt' + contains posteriors in u-space (untransformed) of a previous run. + Columns are weight, logl, param1, param2, ... + min_num_samples: int + minimum number of samples in the usample_filename file required. + Too few samples will give a poor approximation. + + Other Parameters + ----------------- + param_names: list + loglike: function + transform: function + vectorized: bool + + Returns + --------- + aux_param_names: list + new parameter list + aux_loglikelihood: function + new loglikelihood function + aux_transform: function + new prior transform function + vectorized: bool + whether the new functions are vectorized + """ + # load samples + try: + with open(usample_filename) as f: + old_param_names = f.readline().lstrip('#').strip().split() + auxiliary_usamples = np.loadtxt(f) + except IOError: + warnings.warn('not hot-resuming, could not load file "%s"' % usample_filename, stacklevel=2) + return param_names, loglike, transform, vectorized + + ulogl = auxiliary_usamples[:,1] + uweights_full = auxiliary_usamples[:,0] * np.exp(ulogl - ulogl.max()) + mask = uweights_full > 0 + uweights = uweights_full[mask] + uweights /= uweights.sum() + upoints = auxiliary_usamples[mask,2:] + del auxiliary_usamples + + nsamples = len(upoints) + if nsamples < min_num_samples: + raise ValueError('file "%s" has too few samples (%d) to hot-resume' % (usample_filename, nsamples)) + + # check that the parameter meanings have not changed + if old_param_names != ['weight', 'logl'] + param_names: + raise ValueError('file "%s" has parameters %s, expected %s, cannot hot-resume.' % (usample_filename, old_param_names, param_names)) + + return get_auxiliary_contbox_parameterization( + param_names, loglike=loglike, transform=transform, + vectorized=vectorized, + upoints=upoints, + uweights=uweights, + ) + + +class ReactiveNestedSampler: """Nested sampler with reactive exploration strategy. Storage & resume capable, optionally MPI parallelised. @@ -971,6 +1086,10 @@ def __init__(self, are updated until the live point order differs. Otherwise, behaves like resume. + run_num: int or None + If resume=='subfolder', this is the subfolder number. + Automatically increments if set to None. + wrapped_params: list of bools indicating whether this parameter wraps around (circular parameter). @@ -1015,6 +1134,7 @@ def __init__(self, self.sampler = 'reactive-nested' self.x_dim = x_dim + self.transform_layer_class = LocalAffineLayer if x_dim > 1 else ScalingLayer self.derivedparamnames = derived_param_names self.num_bootstraps = int(num_bootstraps) num_derived = len(self.derivedparamnames) @@ -1289,10 +1409,78 @@ def _widen_nodes(self, weighted_parents, weights, nnodes_needed, update_interval return target_min_num_children + def _widen_roots_beyond_initial_plateau(self, nroots, num_warn, num_stop): + """Widen roots, but populate ahead of initial plateau. + + calls _widen_roots, and if there are several points with the same + value equal to the lowest loglikelihood, widens some more until + there are `nroots`-1 that are different to the lowest + loglikelihood value. + + Parameters + ----------- + nroots: int + Number of root live points, after the plateau is traversed. + + num_warn: int + Warn if the number of root live points reached this. + + num_stop: int + Do not increasing the number of root live points beyond this limit. + + """ + nroots_needed = nroots + user_has_been_warned = False + while True: + self._widen_roots(nroots_needed) + Ls = np.array([node.value for node in self.root.children]) + Lmin = np.min(Ls) + if self.log and nroots_needed > num_warn and not user_has_been_warned: + self.logger.warning("""Warning: The log-likelihood has a large plateau with L=%g. + + Probably you are returning a low value when the parameters are problematic/unphysical. + ultranest can handle this correctly, by discarding live points with the same loglikelihood. + (arxiv:2005.08602 arxiv:2010.13884). To mitigate running out of live points, + the initial number of live points is increased. But now this has reached over %d points. + + You can avoid this making the loglikelihood increase towards where the good region is. + For example, let's say you have two parameters where the sum must be below 1. Replace this: + + if params[0] + params[1] > 1: + return -1e300 + + with: + + if params[0] + params[1] > 1: + return -1e300 * (params[0] + params[1]) + + The current strategy will continue until %d live points are reached. + It is safe to ignore this warning.""", Lmin, num_warn, num_stop) + user_has_been_warned = True + + if nroots_needed >= num_stop: + break + P = (Ls == Lmin).sum() + if 1 < P < len(Ls) and len(Ls) - P + 1 < nroots: + # guess the number of points needed: P-1 are useless + if self.log: + self.logger.debug( + 'Found plateau of %d/%d initial points at L=%g. ' + 'Avoid this by a continuously increasing loglikelihood towards good regions.', + P, nroots_needed, Lmin) + nroots_needed = min(num_stop, nroots_needed + (P - 1)) + else: + break + def _widen_roots(self, nroots): """Ensure root has `nroots` children. Sample from prior to fill up (if needed). + + Parameters + ----------- + nroots: int + Number of root live points, after the plateau is traversed. """ if self.log and len(self.root.children) > 0: self.logger.info('Widening roots to %d live points (have %d already) ...', nroots, len(self.root.children)) @@ -1309,7 +1497,7 @@ def _widen_roots(self, nroots): if self.log and self.use_point_stack: # try to resume: # self.logger.info('Resuming...') - for i in range(nnewroots): + for _i in range(nnewroots): rowid, row = self.pointstore.pop(-np.inf) if row is None: break @@ -1337,17 +1525,18 @@ def _widen_roots(self, nroots): if self.log and num_live_points_missing > 0: self.logger.info('Sampling %d live points from prior ...', num_live_points_missing) if num_live_points_missing > 0: - if self.mpi_rank != 0: - num_live_points_todo = num_live_points_missing // self.mpi_size - else: - # rank 0 picks up what the others did not do - num_live_points_todo = num_live_points_missing - (num_live_points_missing // self.mpi_size) * (self.mpi_size - 1) - - active_u = np.random.uniform(size=(num_live_points_todo, self.x_dim)) - active_v = self.transform(active_u) - active_logl = self.loglike(active_v) + num_live_points_todo = distributed_work_chunk_size(num_live_points_missing, self.mpi_rank, self.mpi_size) self.ncall += num_live_points_missing + if num_live_points_todo > 0: + active_u = np.random.uniform(size=(num_live_points_todo, self.x_dim)) + active_v = self.transform(active_u) + active_logl = self.loglike(active_v) + else: + active_u = np.empty((0, self.x_dim)) + active_v = np.empty((0, self.num_params)) + active_logl = np.empty((0,)) + if self.use_mpi: recv_samples = self.comm.gather(active_u, root=0) recv_samplesv = self.comm.gather(active_v, root=0) @@ -1389,11 +1578,14 @@ def _widen_roots(self, nroots): def _adaptive_strategy_advice(self, Lmin, parallel_values, main_iterator, minimal_widths, frac_remain, Lepsilon): """Check if integration is done. + Returns range where more sampling is needed + Returns -------- - Llo, Lhi: floats - range where more sampling is needed - if done, both are nan + Llo: float + lower log-likelihood bound, nan if done + Lhi: float + lower log-likelihood bound, nan if done Parameters ----------- @@ -1407,7 +1599,8 @@ def _adaptive_strategy_advice(self, Lmin, parallel_values, main_iterator, minima current width required frac_remain: float maximum fraction of integral in remainder for termination - + Lepsilon: float + loglikelihood accuracy threshold """ Ls = parallel_values.copy() Ls.sort() @@ -1494,7 +1687,7 @@ def _find_strategy(self, saved_logl, main_iterator, dlogz, dKL, min_ess): Llo_KL = np.inf Lhi_KL = -np.inf - for i, (pi, dKLi, logwi) in enumerate(zip(p.transpose(), dKLtot, other_logw)): + for pi, dKLi, logwi in zip(p.transpose(), dKLtot, other_logw): if dKLi > dKL: ilo, ihi = _get_cumsum_range(pi, 1. / 400) # ilo and ihi are most likely missing in this iterator @@ -1524,9 +1717,8 @@ def _find_strategy(self, saved_logl, main_iterator, dlogz, dKL, min_ess): logzerr_tail = logaddexp(log(tail_fraction) + main_iterator.logZ, main_iterator.logZ) - main_iterator.logZ maxlogzerr = max(main_iterator.logZerr, deltalogZ.max(), main_iterator.logZerr_bs) if maxlogzerr > dlogz: - if logzerr_tail > maxlogzerr: - if self.log: - self.logger.info("logz error is dominated by tail. Decrease frac_remain to make progress.") + if self.log and logzerr_tail > maxlogzerr: + self.logger.info("logz error is dominated by tail. Decrease frac_remain to make progress.") # very convervative estimation using all iterations # this punishes short intervals with many live points niter_max = len(saved_logl) @@ -1543,7 +1735,7 @@ def _find_strategy(self, saved_logl, main_iterator, dlogz, dKL, min_ess): with np.errstate(divide='ignore', invalid='ignore'): widthratio = 1 - np.exp(logweights[1:,0] - logweights[:-1,0]) nlive = 1. / np.log((1 - np.sqrt(1 - 4 * widthratio)) / (2 * widthratio)) - nlive[~(nlive > 1)] = 1 + nlive[~np.logical_and(np.isfinite(nlive), nlive > 1)] = 1 # build iteration groups nlive_sets, niter = np.unique(nlive.astype(int), return_counts=True) @@ -1628,7 +1820,7 @@ def _refill_samples(self, Lmin, ndraw, nit): warning_message = warning_message1 + (warning_message2 % (' (stored for you in %s.csv)' % debug_filename)) else: warning_message = warning_message1 + warning_message2 % '' - warnings.warn(warning_message) + warnings.warn(warning_message, stacklevel=2) logl_region = self.loglike(self.transform(self.region.u)) if (logl_region == Lmin).all(): raise ValueError( @@ -1655,10 +1847,22 @@ def _create_point(self, Lmin, ndraw, active_u, active_values): number of points to try to sample at once active_u: array of floats current live points - active_values + active_values: array loglikelihoods of current live points """ + if self.stepsampler is None: + assert self.region.inside(active_u).any(), \ + ("None of the live points satisfies the current region!", + self.region.maxradiussq, self.region.u, self.region.unormed, active_u, + self.region.bbox_lo, + self.region.bbox_hi, + self.region.ellipsoid_cov, + self.region.ellipsoid_center, + self.region.ellipsoid_invcov, + self.region.ellipsoid_cov, + ) + nit = 0 while True: ib = self.ib @@ -1685,10 +1889,6 @@ def _create_point(self, Lmin, ndraw, active_u, active_values): # skip if we already know it is not useful ib = 0 if np.isfinite(self.likes[0]) else 1 - assert self.region.inside(active_u).any(), \ - ("None of the live points satisfies the current region!", - self.region.maxradiussq, self.region.u, self.region.unormed, active_u) - use_stepsampler = self.stepsampler is not None while ib >= len(self.samples): ib = 0 @@ -1791,10 +1991,7 @@ def _update_region( if self.region is None: # if self.log: # self.logger.debug("building first region ...") - if self.x_dim > 1: - self.transformLayer = AffineLayer(wrapped_dims=self.wrapped_axes) - else: - self.transformLayer = ScalingLayer(wrapped_dims=self.wrapped_axes) + self.transformLayer = self.transform_layer_class(wrapped_dims=self.wrapped_axes) self.transformLayer.optimize(active_u, active_u, minvol=minvol) self.region = self.region_class(active_u, self.transformLayer) self.region_nodes = active_node_ids.copy() @@ -1832,8 +2029,8 @@ def _update_region( # instead, track the clusters from before by matching manually oldt = self.transformLayer.transform(oldu) - clusterids = np.zeros(len(active_u), dtype=int) - nnearby = np.empty(len(self.region.unormed), dtype=int) + clusterids = np.zeros(len(active_u), dtype=int_t) + nnearby = np.empty(len(self.region.unormed), dtype=int_t) for ci in np.unique(self.transformLayer.clusterids): if ci == 0: continue @@ -1882,11 +2079,10 @@ def _update_region( nextregion = self.region_class(active_u, nextTransformLayer) assert np.isfinite(nextregion.unormed).all() - if not nextTransformLayer.nclusters < 20: - if self.log: - self.logger.info( - "Found a lot of clusters: %d (%d with >1 members)", - nextTransformLayer.nclusters, (cluster_sizes > 1).sum()) + if self.log and not nextTransformLayer.nclusters < 20: + self.logger.info( + "Found a lot of clusters: %d (%d with >1 members)", + nextTransformLayer.nclusters, (cluster_sizes > 1).sum()) # if self.log: # self.logger.info("computing maxradius...") @@ -1894,10 +2090,10 @@ def _update_region( # verify correctness: nextregion.create_ellipsoid(minvol=minvol) - # check if live points are numerically colliding or become linearly dependent + # check if live points are numerically colliding or linearly dependent self.live_points_healthy = len(active_u) > self.x_dim and \ np.all(np.sum(active_u[1:] != active_u[0], axis=0) > self.x_dim) and \ - np.linalg.matrix_rank(nextregion.ellipsoid_cov) + np.linalg.matrix_rank(nextregion.ellipsoid_cov) == self.x_dim assert (nextregion.u == active_u).all() assert np.allclose(nextregion.unormed, nextregion.transformLayer.transform(active_u)) @@ -1906,7 +2102,7 @@ def _update_region( good_region = nextregion.inside(active_u).all() # assert good_region if not good_region and self.log: - self.logger.warning("Proposed region is inconsistent (maxr=%f,enlarge=%f) and will be skipped.", r, f) + self.logger.debug("Proposed region is inconsistent (maxr=%g,enlarge=%g) and will be skipped.", r, f) # avoid cases where every point is its own cluster, # and even the largest cluster has fewer than x_dim points @@ -2001,6 +2197,14 @@ def _should_node_be_expanded( ---------- it: int current iteration + Llo: float + lower loglikelihood bound for the strategy + Lhi: float + upper loglikelihood bound for the strategy + minimal_widths_sequence: list + list of likelihood intervals with minimum number of live points + target_min_num_children: int + minimum number of live points currently targeted node: node The node to consider parallel_values: array of floats @@ -2009,8 +2213,6 @@ def _should_node_be_expanded( maximum number of likelihood function calls allowed max_iters: int maximum number of nested sampling iteration allowed - Llo, Lhi, minimal_widths_sequence, target_min_num_children: - Current strategy parameters live_points_healthy: bool indicates whether the live points have become linearly dependent (covariance not full rank) @@ -2024,6 +2226,8 @@ def _should_node_be_expanded( return False if not live_points_healthy: + if self.log: + self.logger.debug("not expanding, because live points are linearly dependent") return False # some reasons to stop: @@ -2038,6 +2242,8 @@ def _should_node_be_expanded( # in a plateau, only shrink (Fowlie+2020) if (Lmin == parallel_values).sum() > 1: + if self.log: + self.logger.debug("Plateau detected at L=%e, not replacing live point." % Lmin) return False expand_node = False @@ -2087,11 +2293,13 @@ def run( max_num_improvement_loops=-1, min_num_live_points=400, cluster_num_live_points=40, - insertion_test_window=10, insertion_test_zscore_threshold=4, + insertion_test_window=10, region_class=MLFriends, + widen_before_initial_plateau_num_warn=10000, + widen_before_initial_plateau_num_max=50000, ): - """Run until target convergence criteria are fulfilled. + r"""Run until target convergence criteria are fulfilled. Parameters ---------- @@ -2111,7 +2319,7 @@ def run( viz_callback: function callback function when region was rebuilt. Allows to show current state of the live points. - See :func:`nicelogger` or :class:`LivePointsWidget`. + See :py:func:`nicelogger` or :py:class:`LivePointsWidget`. If no output desired, set to False. dlogz: float @@ -2158,11 +2366,97 @@ def run( insertion_test_window: int Number of iterations after which the insertion order test is reset. - region_class: MLFriends or RobustEllipsoidRegion + region_class: :py:class:`MLFriends` or :py:class:`RobustEllipsoidRegion` or :py:class:`SimpleRegion` Whether to use MLFriends+ellipsoidal+tellipsoidal region (better for multi-modal problems) - or just ellipsoidal sampling (faster for high-dimensional, gaussian-like problems). + or just ellipsoidal sampling (faster for high-dimensional, gaussian-like problems) + or a axis-aligned ellipsoid (fastest, to be combined with slice sampling). + + widen_before_initial_plateau_num_warn: int + If a likelihood plateau is encountered, increase the number + of initial live points so that once the plateau is traversed, + *min_num_live_points* live points remain. + If the number exceeds *widen_before_initial_plateau_num_warn*, + a warning is raised. + + widen_before_initial_plateau_num_max: int + If a likelihood plateau is encountered, increase the number + of initial live points so that once the plateau is traversed, + *min_num_live_points* live points remain, but not more than + *widen_before_initial_plateau_num_warn*. + + + Returns + ------- + results (dict): Results dictionary, with the following entries: + + - samples (ndarray): re-weighted posterior samples: distributed according + to :math:`p(\theta | d)` - these points are not sorted, and can be assumed + to have been randomly shuffled. + See :py:func:`ultranest.utils.resample_equal` for more details. + - logz (float64): natural logarithm of the evidence + :math:`\log Z = \log \int p(d|\theta) p(\theta) \text{d}\theta` + - logzerr (float64): global estimate of the :math:`1\sigma` error on + :math:`\log Z` + (`can be safely assumed to be Gaussian `_); + obtained as the quadratic sum of ``logz_bs`` and ``logz_tail``. + Users are advised to use ``logz`` :math:`\pm` ``logzerr`` + as the best estimate for the evidence and its error. + - niter (int): number of sampler iterations + - ncall (int): total number of likelihood evaluations (accepted and not) + - logz_bs (float64): estimate of :math:`\log Z` from bootstrapping - + for details, see the + `ultranest paper `_ + - logzerr_bs (float64): estimate of the error on the of :math:`\log Z` + from bootstrapping + - logz_single (float64): estimate of :math:`\log Z` from a single sampler + - logzerr_single (float64): estimate of the error :math:`\log Z` from a + single sampler, obtained as :math:`\sqrt{H / n_{\text{live}}}` + - logzerr_tail (float64): contribution of the tail (i.e. the terminal + leaves of the tree) to the error on :math:`\log Z` (?) + - ess (float64): effective sample size, i.e. number of samples divided by + the estimated correlation length, estimated as + :math:`N / (1 + N^{-1} \sum_i (N w_i - 1)^2)` where :math:`w_i` are + the sample weights while :math:`N` is the number of samples + - H (float64): `information gained `_ + - Herr (float64): (Gaussian) :math:`1\sigma` error on :math:`H` + - posterior (dict): summary information on the posterior marginal distributions for each parameter - + a dictionary of lists each with as many items as the fit parameters, + indexed as :math:`\theta_i` in the following: + + - mean (list): expectation value of :math:`\theta_i` + - stdev (list): standard deviation of :math:`\theta_i` + - median (list): median of :math:`\theta_i` + - errlo (list): one-sigma lower quantile of the marginal for :math:`\theta_i`, i.e. 15.8655% quantile + - errup (list): one-sigma upper quantile of the marginal for :math:`\theta_i`, i.e. 84.1345% quantile + - information_gain_bits (list): information gain from the marginal prior on :math:`\theta_i` to the posterior + + - weighted_samples (dict): weighted samples from the posterior, as computed during sampling, + sorted by their log-likelihood value + + - upoints (ndarray): sample locations in the unit cube :math:`[0, 1]^{d}`, + where :math:`d` is the number of parameters - the shape is ``n_iter`` by :math:`d` + - points (ndarray): sample locations in the physical, user-provided space (same shape as ``upoints``) + - weights (ndarray): sample weights - shape ``n_iter``, they sum to 1 + - logw (ndarray): logs of the sample weights (?) + - bootstrapped_weights (ndarray): bootstrapped estimate of the sample weights + - logl (ndarray): log-likelihood values at the sample points + + - maximum_likelihood (dict): summary information on the maximum likelihood value + :math:`\theta_{ML}` found by the posterior exploration + + - logl (float64): value of the log-likelihood at this point: :math:`\log p(d | \theta_{ML})` + - point (list): coordinates of :math:`\theta_{ML}` in the physical space + - point_untransformed (list): coordinates of :math:`\theta_{ML}` in the unit cube :math:`[0, 1]^{d}` + + - paramnames (list): input parameter names + - insertion_order_MWW_test (dict): results for the Mann-Whitney U-test; + for more information, see the :py:class:`ultranest.netiter.MultiCounter` class + or `section 4.5.2 of Buchner 2023 `_ + + - independent_iterations (float): shortest insertion order test run length + - converged (bool): whether the run is converged according to the MWW test, at the given threshold """ - for result in self.run_iter( + for _result in self.run_iter( update_interval_volume_fraction=update_interval_volume_fraction, update_interval_ncall=update_interval_ncall, log_interval=log_interval, @@ -2177,6 +2471,8 @@ def run( insertion_test_window=insertion_test_window, insertion_test_zscore_threshold=insertion_test_zscore_threshold, region_class=region_class, + widen_before_initial_plateau_num_warn=widen_before_initial_plateau_num_warn, + widen_before_initial_plateau_num_max=widen_before_initial_plateau_num_max, ): if self.log: self.logger.debug("did a run_iter pass!") @@ -2186,9 +2482,9 @@ def run( return self.results - def run_iter( + def run_iter( # noqa: DOC101,DOC103 self, - update_interval_volume_fraction=0.2, + update_interval_volume_fraction=0.8, update_interval_ncall=None, log_interval=None, dlogz=0.5, @@ -2205,16 +2501,24 @@ def run_iter( viz_callback='auto', insertion_test_window=10000, insertion_test_zscore_threshold=2, - region_class=MLFriends + region_class=MLFriends, + widen_before_initial_plateau_num_warn=10000, + widen_before_initial_plateau_num_max=50000, ): - """Iterate towards convergence. + r"""Iterate towards convergence. Use as an iterator like so:: for result in sampler.run_iter(...): print('lnZ = %(logz).2f +- %(logzerr).2f' % result) - Parameters as described in run() method. + Parameters are described in the :py:func:`ReactiveNestedSampler.run` method. + + Yields + ------ + results (dict): + Results dictionary computed at the current iteration, with the same + keys as discussed in the :py:meth:`run` method. """ # frac_remain=1 means 1:1 -> dlogz=log(0.5) # frac_remain=0.1 means 1:10 -> dlogz=log(0.1) @@ -2253,7 +2557,9 @@ def run_iter( if viz_callback == 'auto': viz_callback = get_default_viz_callback() - self._widen_roots(min_num_live_points) + self._widen_roots_beyond_initial_plateau( + min_num_live_points, + widen_before_initial_plateau_num_warn, widen_before_initial_plateau_num_max) Llo, Lhi = -np.inf, np.inf Lmax = -np.inf @@ -2313,7 +2619,7 @@ def run_iter( self.ib = 0 self.samples = [] if self.draw_multiple: - ndraw = 100 + ndraw = self.ndraw_min else: ndraw = 40 self.pointstore.reset() @@ -2386,7 +2692,7 @@ def run_iter( _, cluster_sizes = np.unique(self.region.transformLayer.clusterids, return_counts=True) nclusters = (cluster_sizes > 1).sum() - region_sequence.append((Lmin, nlive, nclusters)) + region_sequence.append((Lmin, nlive, nclusters, np.max(active_values))) # next_update_interval_ncall = self.ncall + (update_interval_ncall or nlive) next_update_interval_volume = main_iterator.logVolremaining + update_interval_volume_log_fraction @@ -2406,10 +2712,12 @@ def run_iter( paramlims=self.transform_limits, order_test_correlation=insertion_test_quality, order_test_direction=insertion_test_direction, + stepsampler_info=self.stepsampler.get_info_dict() if hasattr(self.stepsampler, 'get_info_dict') else {} ), region=self.region, transformLayer=self.transformLayer, region_fresh=region_fresh, ) + if self.log: self.pointstore.flush() if nlive < cluster_num_live_points * nclusters and improvement_it < max_num_improvement_loops: @@ -2447,7 +2755,7 @@ def run_iter( # move also the ellipsoid self.region.ellipsoid_center = np.mean(self.region.u, axis=0) if self.tregion: - self.tregion.ellipsoid_center = np.mean(active_p, axis=0) + self.tregion.update_center(np.mean(active_p, axis=0)) # if we track the cluster assignment, then in the next round # the ids with the same members are likely to have the same id @@ -2480,8 +2788,8 @@ def run_iter( np.inf if ncall_here == 0 else it_here * 100 / ncall_here, nlive)) sys.stdout.flush() - self.logger.debug('iteration=%d, ncalls=%d, logz=%.2f, remainder_fraction=%.4f%%, Lmin=%.2f, Lmax=%.2f' % ( - it, self.ncall, main_iterator.logZ, + self.logger.debug('iteration=%d, ncalls=%d, regioncalls=%d, ndraw=%d, logz=%.2f, remainder_fraction=%.4f%%, Lmin=%.2f, Lmax=%.2f' % ( + it, self.ncall, self.ncall_region, ndraw, main_iterator.logZ, 100 * main_iterator.remainder_fraction, Lmin, main_iterator.Lmax)) # if efficiency becomes low, bulk-process larger arrays @@ -2554,14 +2862,14 @@ def run_iter( Lmax = main_iterator.Lmax if len(region_sequence) > 0: - Lmin, nlive, nclusters = region_sequence[-1] + Lmin, nlive, nclusters, Lhi = region_sequence[-1] nnodes_needed = cluster_num_live_points * nclusters if nlive < nnodes_needed: - Llo, Lhi, target_min_num_children_new = self._expand_nodes_before(Lmin, nnodes_needed, update_interval_ncall or nlive) + Llo, _, target_min_num_children_new = self._expand_nodes_before(Lmin, nnodes_needed, update_interval_ncall or nlive) target_min_num_children.update(target_min_num_children_new) # if self.log: # print_tree(self.root.children[::10]) - minimal_widths.append((Llo, Lmin, nnodes_needed)) + minimal_widths.append((Llo, Lhi, nnodes_needed)) Llo, Lhi = -np.inf, np.inf continue @@ -2593,7 +2901,10 @@ def run_iter( if dlogz_min_num_live_points > self.min_num_live_points: # more live points needed throughout to reach target self.min_num_live_points = dlogz_min_num_live_points - self._widen_roots(self.min_num_live_points) + self._widen_roots_beyond_initial_plateau( + self.min_num_live_points, + widen_before_initial_plateau_num_warn, + widen_before_initial_plateau_num_max) elif Llo <= Lhi: # if self.log: @@ -2663,9 +2974,9 @@ def _update_results(self, main_iterator, saved_logl, saved_nodeids): np.savetxt( os.path.join(self.logs['info'], 'post_summary.csv'), - [np.hstack([results['posterior'][k] for k in ('mean', 'stdev', 'median', 'errlo', 'errup')])], - header=', '.join(['"{0}_mean", "{0}_stdev", "{0}_median", "{0}_errlo", "{0}_errup"'.format(k) - for k in self.paramnames + self.derivedparamnames]), + [[results['posterior'][k][i] for i in range(self.num_params) for k in ('mean', 'stdev', 'median', 'errlo', 'errup')]], + header=','.join(['"{0}_mean","{0}_stdev","{0}_median","{0}_errlo","{0}_errup"'.format(k) + for k in self.paramnames + self.derivedparamnames]), delimiter=',', comments='', ) @@ -2687,8 +2998,15 @@ def store_tree(self): dump_tree(os.path.join(self.logs['results'], 'tree.hdf5'), self.root.children, self.pointpile) - def print_results(self, logZ=True, posterior=True): - """Give summary of marginal likelihood and parameters.""" + def print_results(self, use_unicode=True): + """Give summary of marginal likelihood and parameter posteriors. + + Parameters + ---------- + use_unicode: bool + Whether to print a unicode plot of the posterior distributions + + """ if self.log: print() print('logZ = %(logz).3f +- %(logzerr).3f' % self.results) @@ -2697,6 +3015,8 @@ def print_results(self, logZ=True, posterior=True): print(' tail : logZ = +- %(logzerr_tail).3f' % self.results) print('insert order U test : converged: %(converged)s correlation: %(independent_iterations)s iterations' % ( self.results['insertion_order_MWW_test'])) + if self.stepsampler and hasattr(self.stepsampler, 'print_diagnostic'): + self.stepsampler.print_diagnostic() print() for i, p in enumerate(self.paramnames + self.derivedparamnames): @@ -2704,15 +3024,40 @@ def print_results(self, logZ=True, posterior=True): sigma = v.std() med = v.mean() if sigma == 0: - i = 3 + j = 3 else: - i = max(0, int(-np.floor(np.log10(sigma))) + 1) - fmt = '%%.%df' % i - fmts = '\t'.join([' %-20s' + fmt + " +- " + fmt]) - print(fmts % (p, med, sigma)) + j = max(0, int(-np.floor(np.log10(sigma))) + 1) + fmt = '%%.%df' % j + try: + if not use_unicode: + raise UnicodeEncodeError("") + # make fancy terminal visualisation on a best-effort basis + ' ▁▂▃▄▅▆▇██'.encode(sys.stdout.encoding) + H, edges = np.histogram(v, bins=40) + # add a bit of padding, but not outside parameter limits + lo, hi = edges[0], edges[-1] + step = edges[1] - lo + lo = max(self.transform_limits[i,0], lo - 2 * step) + hi = min(self.transform_limits[i,1], hi + 2 * step) + H, edges = np.histogram(v, bins=np.linspace(lo, hi, 40)) + lo, hi = edges[0], edges[-1] + + dist = ''.join([' ▁▂▃▄▅▆▇██'[i] for i in np.ceil(H * 7 / H.max()).astype(int)]) + print(' %-20s: %-6s│%s│%-6s %s +- %s' % (p, fmt % lo, dist, fmt % hi, fmt % med, fmt % sigma)) + except Exception: + fmts = ' %-20s' + fmt + " +- " + fmt + print(fmts % (p, med, sigma)) + print() def plot(self): - """Make corner, run and trace plots.""" + """Make corner, run and trace plots. + + calls: + + * plot_corner() + * plot_run() + * plot_trace() + """ self.plot_corner() self.plot_run() self.plot_trace() @@ -2729,8 +3074,9 @@ def plot_corner(self): cornerplot(results) """ - from .plot import cornerplot import matplotlib.pyplot as plt + + from .plot import cornerplot if self.log: self.logger.debug('Making corner plot ...') cornerplot(self.results, logger=self.logger if self.log else None) @@ -2751,8 +3097,9 @@ def plot_trace(self): traceplot(results=results, labels=paramnames + derivedparamnames) """ - from .plot import traceplot import matplotlib.pyplot as plt + + from .plot import traceplot if self.log: self.logger.debug('Making trace plot ... ') paramnames = self.paramnames + self.derivedparamnames @@ -2775,8 +3122,9 @@ def plot_run(self): runplot(results=results) """ - from .plot import runplot import matplotlib.pyplot as plt + + from .plot import runplot if self.log: self.logger.debug('Making run plot ... ') # get dynesty-compatible sequences @@ -2807,7 +3155,7 @@ def read_file(log_dir, x_dim, num_bootstraps=20, random=True, verbose=False, che whether to perform MWW insertion order test for assessing convergence Returns - ---------- + ------- sequence: dict contains arrays storing for each iteration estimates of: diff --git a/ultranest/mlfriends.pyx b/ultranest/mlfriends.pyx index d4065c3f..dab7cc3a 100644 --- a/ultranest/mlfriends.pyx +++ b/ultranest/mlfriends.pyx @@ -1,20 +1,38 @@ # cython: language_level=3,annotate=True,profile=True,fast_fail=True,warning_errors=True -"""Construct and sample from region. +""" +Region construction methods +--------------------------- + +Construct and sample from regions of neighbourhoods around the live points. +Includes + +* an efficient implementation of MLFriends, with transformation layers and clustering. + * RadFriends: Buchner (2014) https://arxiv.org/abs/1407.5459 + * MLFriends: Buchner (2019) https://arxiv.org/abs/1707.04476 +* a single-ellipsoid region (Mukherjee et al., 2006, https://arxiv.org/abs/astro-ph/0508461) +* a very fast single-ellipsoid, axis-aligned region, for use with step-samplers in high dimensions -Implements MLFriends efficiently, with transformation layers and clustering. """ import numpy as np cimport numpy as np +np.import_array() from numpy import pi cimport cython +from cython.cimports.libc.math import sqrt + + +ctypedef np.int64_t decl_int_t +int_dtype = np.int64 + @cython.boundscheck(False) @cython.wraparound(False) -cdef count_nearby(np.ndarray[np.float_t, ndim=2] apts, +cdef count_nearby( + np.ndarray[np.float_t, ndim=2] apts, np.ndarray[np.float_t, ndim=2] bpts, np.float_t radiussq, - np.ndarray[np.int_t, ndim=1] nnearby + np.ndarray[decl_int_t, ndim=1] nnearby ): """Count the number of points in ``apts`` within square radius ``radiussq`` for each point ``b`` in `bpts``. @@ -34,8 +52,6 @@ cdef count_nearby(np.ndarray[np.float_t, ndim=2] apts, cdef size_t na = apts.shape[0] cdef size_t nb = bpts.shape[0] cdef size_t ndim = apts.shape[1] - #assert ndim == bpts.shape[1] - #assert nnearby.shape[0] == nb cdef unsigned long i, j cdef np.float_t d @@ -54,10 +70,81 @@ cdef count_nearby(np.ndarray[np.float_t, ndim=2] apts, @cython.boundscheck(False) @cython.wraparound(False) -def find_nearby(np.ndarray[np.float_t, ndim=2] apts, +def _subtract_nearby(np.ndarray[np.float_t, ndim=2] apts, np.ndarray[np.float_t, ndim=2] bpts, np.float_t radiussq): + """Subtract from each point apts the mean of points within square radius `radiussq`, store in bpts. + + Parameters + ---------- + apts: array + points + bpts: array + resulting points + radiussq: float + square of the MLFriends radius + + """ + cdef size_t n = apts.shape[0] + cdef size_t ndim = apts.shape[1] + assert n == bpts.shape[0] + assert ndim == bpts.shape[1] + + cdef unsigned long i, j + cdef size_t nnearby + cdef np.float_t d + + # go through each point + for j in range(n): + # find all nearest points + bpts[j,:] = 0 + nnearby = 0 + for i in range(n): + # check if it is within the radius + d = 0.0 + for k in range(ndim): + d += (apts[i,k] - apts[j,k])**2 + if d <= radiussq: + # accumulate to point average + nnearby += 1 + for k in range(ndim): + bpts[j,k] += apts[i,k] + + # compute and subtract mean + for k in range(ndim): + bpts[j,k] = apts[j,k] - bpts[j,k] / float(nnearby) + + +@cython.boundscheck(False) +@cython.wraparound(False) +def subtract_nearby( + np.ndarray[np.float_t, ndim=2] upoints, + np.float_t maxradiussq): + """Subtract from each point apts the mean of points within square radius `radiussq`, store in bpts. + + Parameters + ---------- + apts: array + points + radiussq: float + square of the MLFriends radius + + Returns + --------- + overlapped_points: + upoints with the nearby centers subtracted. + + """ + upoints_out = np.zeros_like(upoints) + _subtract_nearby(upoints, upoints_out, maxradiussq) + return upoints_out + + +@cython.boundscheck(False) +@cython.wraparound(False) +def find_nearby( + np.ndarray[np.float_t, ndim=2] apts, np.ndarray[np.float_t, ndim=2] bpts, np.float_t radiussq, - np.ndarray[np.int_t, ndim=1] nnearby + np.ndarray[decl_int_t, ndim=1] nnearby ): """Gets the index of a point in `a` within square radius `radiussq`, for each point `b` in `bpts`. @@ -79,8 +166,6 @@ def find_nearby(np.ndarray[np.float_t, ndim=2] apts, cdef size_t na = apts.shape[0] cdef size_t nb = bpts.shape[0] cdef size_t ndim = apts.shape[1] - #assert ndim == bpts.shape[1] - #assert nnearby.shape[0] == nb cdef unsigned long i, j cdef np.float_t d @@ -117,8 +202,6 @@ cdef float compute_maxradiussq(np.ndarray[np.float_t, ndim=2] apts, np.ndarray[n cdef size_t na = apts.shape[0] cdef size_t nb = bpts.shape[0] cdef size_t ndim = apts.shape[1] - #assert ndim == bpts.shape[1] - #assert f.dtype == np.float_t and g.dtype == np.float_t cdef unsigned long i, j cdef np.float_t d @@ -145,7 +228,7 @@ cdef float compute_maxradiussq(np.ndarray[np.float_t, ndim=2] apts, np.ndarray[n @cython.wraparound(False) def compute_mean_pair_distance( np.ndarray[np.float_t, ndim=2] pts, - np.ndarray[np.int_t, ndim=1] clusterids + np.ndarray[decl_int_t, ndim=1] clusterids ): """Compute the average distance between pairs of points. Pairs from different clusters are excluded in the computation. @@ -180,7 +263,7 @@ def compute_mean_pair_distance( pair_dist = 0.0 for k in range(ndim): pair_dist += (pts[i,k] - pts[j,k])**2 - total_dist += pair_dist**0.5 + total_dist += sqrt(pair_dist) Npairs += 1 assert np.isfinite(total_dist), total_dist @@ -193,13 +276,12 @@ cdef _update_clusters( np.ndarray[np.float_t, ndim=2] upoints, np.ndarray[np.float_t, ndim=2] tpoints, np.float_t maxradiussq, - np.ndarray[np.int_t, ndim=1] clusterids, + np.ndarray[decl_int_t, ndim=1] clusterids, ): """same signature as ``update_clusters()``, see there.""" - #print("clustering with maxradiussq %f..." % maxradiussq) assert upoints.shape[0] == tpoints.shape[0], ('different number of points', upoints.shape[0], tpoints.shape[0]) assert upoints.shape[1] == tpoints.shape[1], ('different dimensionality of points', upoints.shape[1], tpoints.shape[1]) - clusteridxs = np.zeros(len(tpoints), dtype=int) + clusteridxs = np.zeros(len(tpoints), dtype=int_dtype) currentclusterid = 1 i = 0 # avoid issues when old clusterids are from a longer array @@ -217,16 +299,16 @@ cdef _update_clusters( break nonmembers = tpoints[nonmembermask,:] - idnearby = np.empty(len(nonmembers), dtype=int) + idnearby = np.empty(len(nonmembers), dtype=int_dtype) members = tpoints[clusteridxs == currentclusterid,:] find_nearby(members, nonmembers, maxradiussq, idnearby) - #print('merging %d into cluster %d of size %d' % (np.count_nonzero(nnearby), currentclusterid, len(members))) + # print('merging %d into cluster %d of size %d' % (np.count_nonzero(nnearby), currentclusterid, len(members))) if (idnearby >= 0).any(): # place into cluster newmembers = nonmembermask newmembers[nonmembermask] = idnearby >= 0 - #print('adding', newmembers.sum()) + # print('adding', newmembers.sum()) clusteridxs[newmembers] = currentclusterid else: # start a new cluster @@ -241,7 +323,7 @@ cdef _update_clusters( assert (clusteridxs > 0).all() nclusters = len(np.unique(clusteridxs)) - #assert np.all(np.unique(clusteridxs) == np.arange(nclusters)+1), (np.unique(clusteridxs), nclusters, np.arange(nclusters)+1) + # assert np.all(np.unique(clusteridxs) == np.arange(nclusters)+1), (np.unique(clusteridxs), nclusters, np.arange(nclusters)+1) if nclusters == 1: overlapped_upoints = upoints else: @@ -260,15 +342,16 @@ cdef _update_clusters( return nclusters, clusteridxs, overlapped_upoints + @cython.boundscheck(False) @cython.wraparound(False) def update_clusters( np.ndarray[np.float_t, ndim=2] upoints, np.ndarray[np.float_t, ndim=2] tpoints, np.float_t maxradiussq, - clusterids = None, + clusterids=None, ): - """Clusters `upoints`, so that clusters are distinct if no + """Clusters `upoints`, so that clusters are distinct if no member pair is within a radius of sqrt(`maxradiussq`). Parameters @@ -297,7 +380,7 @@ def update_clusters( Returned values are based on upoints. """ if clusterids is None: - clusterids = np.zeros(len(tpoints), dtype=int) + clusterids = np.zeros(len(tpoints), dtype=int_dtype) return _update_clusters(upoints, tpoints, maxradiussq, clusterids) @@ -328,15 +411,16 @@ def make_eigvals_positive( except np.linalg.LinAlgError as e: print(a, targetprod) raise e - mask = w < 1.e-10 + mask = w < max(1.e-10, 1e-300**(1. / len(a))) if np.any(mask): - nzprod = np.product(w[~mask]) # product of nonzero eigenvalues + nzprod = np.prod(w[~mask]) # product of nonzero eigenvalues nzeros = mask.sum() # number of zero eigenvalues - w[mask] = (targetprod / nzprod) ** (1./nzeros) # adjust zero eigvals + w[mask] = (targetprod / nzprod) ** (1. / nzeros) # adjust zero eigvals a = np.dot(np.dot(v, np.diag(w)), np.linalg.inv(v)) # re-form cov return a + @cython.boundscheck(False) @cython.wraparound(False) def bounding_ellipsoid( @@ -360,7 +444,6 @@ def bounding_ellipsoid( """ # Function taken from nestle, MIT licensed, (C) kbarbary - npoints = x.shape[0] ndim = x.shape[1] # Calculate covariance of points @@ -426,7 +509,7 @@ class ScalingLayer(object): | ******** | """ if not self.has_wraps: - return + return N, ndims = points.shape self.wrap_cuts = [] @@ -482,14 +565,14 @@ class ScalingLayer(object): self.mean = wrapped_points.mean(axis=0).reshape((1,-1)) self.std = centered_points.std(axis=0).reshape((1,-1)) self.axes = np.diag(self.std[0]) - self.volscale = np.product(self.std) + self.logvolscale = np.sum(np.log(self.std)) self.set_clusterids(clusterids=clusterids, npoints=len(points)) def set_clusterids(self, clusterids=None, npoints=None): """Updates the cluster id assigned to each point.""" if clusterids is None and self.clusterids is None and npoints is not None: # for the beginning, set cluster ids to one for all points - clusterids = np.ones(npoints, dtype=int) + clusterids = np.ones(npoints, dtype=int_dtype) if clusterids is not None: # if we have a value, update self.clusterids = clusterids @@ -514,8 +597,8 @@ class ScalingLayer(object): uwpoints = self.wrap(upoints) tpoints = self.transform(upoints) nclusters, clusteridxs, overlapped_uwpoints = update_clusters(uwpoints, tpoints, maxradiussq, self.clusterids) - #clusteridxs = track_clusters(clusteridxs, self.clusterids) - s = ScalingLayer(nclusters=nclusters, wrapped_dims=self.wrapped_dims, clusterids=clusteridxs) + # clusteridxs = track_clusters(clusteridxs, self.clusterids) + s = self.__class__(nclusters=nclusters, wrapped_dims=self.wrapped_dims, clusterids=clusteridxs) s.optimize(upoints, overlapped_uwpoints) return s @@ -536,10 +619,17 @@ class ScalingLayer(object): u = w.reshape(ww.shape) return u + class AffineLayer(ScalingLayer): """Affine whitening transformation. Learns the covariance of points. + + For learning the next layer's covariance, the clustering + is considered: the sample covariance is computed after subtracting + the cluster mean. This co-centers all clusters and gets + the average cluster shape, avoiding learning a covariance dominated + by the distance between clusters. """ def __init__(self, ctr=0, T=1, invT=1, nclusters=1, wrapped_dims=[], clusterids=None): @@ -552,9 +642,11 @@ class AffineLayer(ScalingLayer): ctr: vector Center of points T: matrix - transformation matrix + Transformation matrix. This matrix whitens the points + to a unit Gaussian. invT: matrix - inverse transformation matrix + Inverse transformation matrix. For transforming a unit + Gaussian into something with the sample cov. nclusters: int number of clusters wrapped_dims: array of bools @@ -591,20 +683,30 @@ class AffineLayer(ScalingLayer): """ self.optimize_wrap(points) wrapped_points = self.wrap(points) + # point center self.ctr = np.mean(wrapped_points, axis=0) + # compute sample covariance cov = np.cov(centered_points, rowvar=0) cov *= (len(self.ctr) + 2) self.cov = cov + # Eigen decomposition of the covariance, with numerical stability eigval, eigvec = np.linalg.eigh(cov) eigvalmin = eigval.max() * 1e-40 eigval[eigval < eigvalmin] = eigvalmin + # Try explicit inversion; if this fails, the error is escalated. a = np.linalg.inv(cov) - self.volscale = np.linalg.det(a)**-0.5 + # log-volume of the space + self.logvolscale = np.linalg.slogdet(a)[1] * -0.5 + # Transformation matrix with the correct scale + # this matrix whitens the points to a unit Gaussian. self.T = eigvec * eigval**-0.5 + # Inverse transformation matrix, for transforming a unit + # Gaussian into something with the sample cov. self.invT = np.linalg.inv(self.T) + # These also are the principle axes of the space self.axes = self.invT - #print('transform used:', self.T, self.invT, 'cov:', cov, 'eigen:', eigval, eigvec) + # print('transform used:', self.T, self.invT, 'cov:', cov, 'eigen:', eigval, eigvec) self.set_clusterids(clusterids=clusterids, npoints=len(points)) def create_new(self, upoints, maxradiussq, minvol=0.): @@ -627,8 +729,8 @@ class AffineLayer(ScalingLayer): uwpoints = self.wrap(upoints) tpoints = self.transform(upoints) nclusters, clusteridxs, overlapped_uwpoints = update_clusters(uwpoints, tpoints, maxradiussq, self.clusterids) - #clusteridxs = track_clusters(clusteridxs, self.clusterids) - s = AffineLayer(nclusters=nclusters, wrapped_dims=self.wrapped_dims, clusterids=clusteridxs) + # clusteridxs = track_clusters(clusteridxs, self.clusterids) + s = self.__class__(nclusters=nclusters, wrapped_dims=self.wrapped_dims, clusterids=clusteridxs) s.optimize(upoints, overlapped_uwpoints, minvol=minvol) return s @@ -649,8 +751,106 @@ class AffineLayer(ScalingLayer): u = w.reshape(ww.shape) return u +class MaxPrincipleGapAffineLayer(AffineLayer): + """Affine whitening transformation. + + For learning the next layer's covariance, the clustering + and principal axis is considered: + the sample covariance is computed after subtracting + the cluster mean. All points are projected onto the line + defined by the principle axis vector starting from the origin. + Then, on the sorted line positions, the largest gap is identified. + All points before the gap are mean-subtracted, and all points + after the gap are mean-subtracted. Then, the final + sample covariance is computed. This should give a more "local" + covariance, even in the case where clusters could not yet be + clearly identified. + """ + + def create_new(self, upoints, maxradiussq, minvol=0.): + """Learn next layer from this optimized layer's clustering. + + Parameters + ---------- + upoints: array + points to use for optimize (in u-space) + maxradiussq: float + square of the MLFriends radius + minvol: float + Minimum volume to regularize sample covariance + + Returns + --------- + A new, optimized MaxPrincipleGapAffineLayer. + """ + # perform clustering in transformed space + uwpoints = self.wrap(upoints) + tpoints = self.transform(upoints) + nclusters, clusteridxs, overlapped_uwpoints = update_clusters(uwpoints, tpoints, maxradiussq, self.clusterids) + + cov = np.cov(overlapped_uwpoints, rowvar=0) + cov *= (len(self.ctr) + 2) + eigval, eigvec = np.linalg.eigh(cov) + # identify principle axis + principal_vector = eigvec[:, -1] + # project all overlapped_uwpoints onto principle axis, + # obtaining position on line + t = np.dot(overlapped_uwpoints - overlapped_uwpoints.mean(axis=0).reshape((1,-1)), principal_vector) + # sort positions, identify largest gap + tsorted = np.sort(t) + tgapindex = np.argmax(np.diff(tsorted)) + # compute center of largest gap + tsep = (tsorted[tgapindex] + tsorted[tgapindex + 1]) / 2 + # assign point to left and right cluster + left_cluster = t < tsep + # subtract the respective cluster mean from overlapped_uwpoints + left_mean = overlapped_uwpoints[left_cluster, :].mean(axis=0) + right_mean = overlapped_uwpoints[~left_cluster, :].mean(axis=0) + halved_overlapped_uwpoints = overlapped_uwpoints.copy() + halved_overlapped_uwpoints[left_cluster, :] -= left_mean + halved_overlapped_uwpoints[~left_cluster, :] -= right_mean + + # re-optimize with the new subtracted points + s = MaxPrincipleGapAffineLayer(nclusters=nclusters, wrapped_dims=self.wrapped_dims, clusterids=clusteridxs) + s.optimize(upoints, halved_overlapped_uwpoints, minvol=minvol) + return s + + +class LocalAffineLayer(AffineLayer): + """Affine whitening transformation. + + For learning the next layer's covariance, the points within + the MLradius are co-centered. This should give a more "local" + covariance. + """ -def vol_prefactor(np.int_t n): + def create_new(self, upoints, maxradiussq, minvol=0.): + """Learn next layer from this optimized layer's clustering. + + Parameters + ---------- + upoints: array + points to use for optimize (in u-space) + maxradiussq: float + square of the MLFriends radius + minvol: float + Minimum volume to regularize sample covariance + + Returns + --------- + A new, optimized LocalAffineLayer. + """ + # perform clustering in transformed space + uwpoints = self.wrap(upoints) + tpoints = self.transform(upoints) + nclusters, clusteridxs, overlapped_uwpoints = update_clusters(uwpoints, tpoints, maxradiussq, self.clusterids) + s = self.__class__(nclusters=nclusters, wrapped_dims=self.wrapped_dims, clusterids=clusteridxs) + local_overlapped_uwpoints = subtract_nearby(uwpoints, maxradiussq) + s.optimize(upoints, local_overlapped_uwpoints, minvol=minvol) + return s + + +cpdef vol_prefactor(int n): """Volume constant for an ``n``-dimensional sphere. for ``n`` even: $$ (2pi)^(n /2) / (2 * 4 * ... * n)$$ @@ -678,6 +878,7 @@ def vol_prefactor(np.int_t n): return f + def _inside_ellipsoid( np.ndarray[np.float_t, ndim=2] points, np.ndarray[np.float_t, ndim=1] ellipsoid_center, @@ -710,6 +911,7 @@ def _inside_ellipsoid( # (r <= 1) means inside return r <= square_radius + class MLFriends(object): """MLFriends region. @@ -750,7 +952,7 @@ class MLFriends(object): def estimate_volume(self): """Estimate the order of magnitude of the volume around a single point - given the current transformLayer and + given the current transformLayer. Does not account for: * the number of live points @@ -759,12 +961,13 @@ class MLFriends(object): Returns ------- - volume (float) + volume: float + Volume """ r = self.maxradiussq**0.5 N, ndim = self.u.shape # how large is a sphere of size r in untransformed coordinates? - return np.log(self.transformLayer.volscale) + np.log(r) * ndim #+ np.log(vol_prefactor(ndim)) + return self.transformLayer.logvolscale + np.log(r) * ndim #+ np.log(vol_prefactor(ndim)) def set_transformLayer(self, transformLayer): """Update transformation layer. Invalidates attribute `maxradius`. @@ -847,7 +1050,7 @@ class MLFriends(object): # compute distances from a to b maxd = max(maxd, compute_maxradiussq( - self.unormed[selected,:], + self.unormed[selected,:], self.unormed[~selected,:])) # compute enlargement of bounding ellipsoid @@ -877,11 +1080,11 @@ class MLFriends(object): # generate points near random existing points idx = np.random.randint(N, size=nsamples) v = np.random.normal(size=(nsamples, ndim)) - v *= (np.random.uniform(size=nsamples)**(1./ndim) / np.linalg.norm(v, axis=1)).reshape((-1, 1)) + v *= (np.random.uniform(size=nsamples)**(1. / ndim) / np.linalg.norm(v, axis=1)).reshape((-1, 1)) v = self.unormed[idx,:] + v * self.maxradiussq**0.5 # count how many are around - nnearby = np.empty(nsamples, dtype=int) + nnearby = np.empty(nsamples, dtype=int_dtype) count_nearby(self.unormed, v, self.maxradiussq, nnearby) vmask = np.random.uniform(high=nnearby) < 1 w = self.transformLayer.untransform(v[vmask,:]) @@ -903,7 +1106,7 @@ class MLFriends(object): wmask = self.inside_ellipsoid(u) # check if inside region in transformed space v = self.transformLayer.transform(u[wmask,:]) - idnearby = np.empty(len(v), dtype=int) + idnearby = np.empty(len(v), dtype=int_dtype) find_nearby(self.unormed, v, self.maxradiussq, idnearby) vmask = idnearby >= 0 return u[wmask,:][vmask,:] @@ -917,8 +1120,8 @@ class MLFriends(object): """ N, ndim = self.u.shape # draw from rectangle in transformed space - v = np.random.uniform(self.bbox_lo - self.maxradiussq, self.bbox_hi + self.maxradiussq, size=(nsamples, ndim)) - idnearby = np.empty(nsamples, dtype=int) + v = np.random.uniform(self.bbox_lo - self.maxradiussq**0.5, self.bbox_hi + self.maxradiussq**0.5, size=(nsamples, ndim)) + idnearby = np.empty(nsamples, dtype=int_dtype) find_nearby(self.unormed, v, self.maxradiussq, idnearby) vmask = idnearby >= 0 @@ -950,7 +1153,7 @@ class MLFriends(object): wmask = np.logical_and(w > 0, w < 1).all(axis=1) v = self.transformLayer.transform(w[wmask,:]) - idnearby = np.empty(len(v), dtype=int) + idnearby = np.empty(len(v), dtype=int_dtype) find_nearby(self.unormed, v, self.maxradiussq, idnearby) vmask = idnearby >= 0 @@ -965,7 +1168,7 @@ class MLFriends(object): ---------- nsamples: int number of samples to draw - + Returns ------- samples: array of shape (nsamples, dimension) @@ -977,7 +1180,7 @@ class MLFriends(object): if len(samples) == 0: # no result, choose another method self.current_sampling_method = self.sampling_methods[np.random.randint(len(self.sampling_methods))] - #print("switching to %s" % self.current_sampling_method) + # print("switching to %s" % self.current_sampling_method) return samples def inside(self, pts): @@ -997,11 +1200,11 @@ class MLFriends(object): """ # require points to be inside bounding ellipsoid mask = self.inside_ellipsoid(pts) - + if mask.any(): # additionally require points to be near neighbours bpts = self.transformLayer.transform(pts[mask,:]) - idnearby = np.empty(len(bpts), dtype=int) + idnearby = np.empty(len(bpts), dtype=int_dtype) find_nearby(self.unormed, bpts, self.maxradiussq, idnearby) mask[mask] = idnearby >= 0 @@ -1084,7 +1287,7 @@ class RobustEllipsoidRegion(MLFriends): self.set_transformLayer(transformLayer) self.sampling_methods = [ - self.sample_from_transformed_boundingbox, + #self.sample_from_transformed_boundingbox, self.sample_from_boundingbox, self.sample_from_wrapping_ellipsoid ] @@ -1102,13 +1305,7 @@ class RobustEllipsoidRegion(MLFriends): # draw from unit cube in prior space u = np.random.uniform(size=(nsamples, ndim)) wmask = self.inside_ellipsoid(u) - # check if inside region in transformed space - v = self.transformLayer.transform(u[wmask,:]) - vmask = np.logical_and( - v > (self.bbox_lo - self.maxradiussq).reshape((1, -1)), - v < (self.bbox_hi + self.maxradiussq).reshape((1, -1)) - ).all(axis=1) - return u[wmask,:][vmask,:] + return u[wmask,:] def sample_from_transformed_boundingbox(self, nsamples=100): """Draw uniformly sampled points from MLFriends region. @@ -1148,13 +1345,7 @@ class RobustEllipsoidRegion(MLFriends): #assert self.inside_ellipsoid(w).all() wmask = np.logical_and(w > 0, w < 1).all(axis=1) - v = self.transformLayer.transform(w[wmask,:]) - vmask = np.logical_and( - v > (self.bbox_lo - self.maxradiussq).reshape((1, -1)), - v < (self.bbox_hi + self.maxradiussq).reshape((1, -1)) - ).all(axis=1) - - return w[wmask,:][vmask] + return w[wmask,:] def sample(self, nsamples=100): """Draw uniformly sampled points from MLFriends region. @@ -1165,7 +1356,7 @@ class RobustEllipsoidRegion(MLFriends): ---------- nsamples: int number of samples to draw - + Returns ------- samples: array of shape (nsamples, dimension) @@ -1196,18 +1387,7 @@ class RobustEllipsoidRegion(MLFriends): """ # require points to be inside bounding ellipsoid - mask = self.inside_ellipsoid(pts) - - if mask.any(): - # additionally require points to be near neighbours - v = self.transformLayer.transform(pts[mask,:]) - vmask = np.logical_and( - v > (self.bbox_lo - self.maxradiussq).reshape((1, -1)), - v < (self.bbox_hi + self.maxradiussq).reshape((1, -1)) - ).all(axis=1) - mask[mask] = vmask - - return mask + return self.inside_ellipsoid(pts) def compute_enlargement(self, nbootstraps=50, minvol=0., rng=np.random): """Return MLFriends radius and ellipsoid enlargement using bootstrapping. @@ -1231,6 +1411,8 @@ class RobustEllipsoidRegion(MLFriends): square radius of enclosing ellipsoid. """ N, ndim = self.u.shape + if N < ndim + 1: + raise FloatingPointError('not enough live points to compute covariance') assert np.isfinite(self.unormed).all(), self.unormed selected = np.empty(N, dtype=bool) maxd = 1e300 @@ -1257,6 +1439,115 @@ class RobustEllipsoidRegion(MLFriends): assert maxf > 0, (maxf, self.u, self.unormed) return maxd, maxf + def estimate_volume(self): + """Estimate the volume of the ellipsoid. + + Does not account for the intersection with the unit cube borders. + + Returns + ------- + logvolume: float + logarithm of the volume. + """ + ndim = len(self.ellipsoid_cov) + sign, logvol = np.linalg.slogdet(self.ellipsoid_cov) + if sign > 0: + return logvol + ndim * np.log(self.enlarge) + else: + return -1e300 + + +class SimpleRegion(RobustEllipsoidRegion): + """Axis-aligned ellipsoidal region. + + Defines a region around nested sampling live points for + + 1. checking whether a proposed point likely also fulfills the + likelihood constraints + 2. proposing new points. + + Learns geometry of region from existing live points. + """ + + def create_ellipsoid(self, minvol=0.0): + """Create wrapping ellipsoid and store its center and covariance. + + Parameters + ---------- + minvol: float + If positive, make sure ellipsoid has at least this volume. + """ + assert self.enlarge is not None + # compute enlargement of bounding ellipsoid + ctr = np.mean(self.u, axis=0) + var = np.var(self.u, axis=0) + a = np.diag(1. / var) + cov = np.diag(var) + + self.ellipsoid_center = ctr + self.ellipsoid_invcov = a + self.ellipsoid_cov = cov + + l, v = np.linalg.eigh(a) + self.ellipsoid_axlens = 1. / np.sqrt(l) + self.ellipsoid_axes = np.dot(v, np.diag(self.ellipsoid_axlens)) + self.ellipsoid_axes_T = self.ellipsoid_axes.transpose() + + l2, v2 = np.linalg.eigh(cov) + self.ellipsoid_inv_axlens = 1. / np.sqrt(l2) + self.ellipsoid_inv_axes = np.dot(v2, np.diag(self.ellipsoid_inv_axlens)) + + + def compute_enlargement(self, nbootstraps=50, minvol=0., rng=np.random): + """Return MLFriends radius and ellipsoid enlargement using bootstrapping. + + The wrapping ellipsoid covariance is determined in each bootstrap round. + + Parameters + ---------- + nbootstraps: int + number of bootstrapping rounds + minvol: float + minimum volume to enforce to wrapping ellipsoid + rng: + random number generator + + Returns + ------- + max_distance: float + square radius of MLFriends algorithm + max_radius: float + square radius of enclosing ellipsoid. + """ + N, ndim = self.u.shape + assert np.isfinite(self.u).all(), self.u + assert np.isfinite(self.unormed).all(), self.unormed + selected = np.empty(N, dtype=bool) + maxd = 1e300 + maxf = 0.0 + if N < ndim + 1: + raise FloatingPointError('not enough live points to compute variance') + + for i in range(nbootstraps): + idx = rng.randint(N, size=N) + selected[:] = False + selected[idx] = True + + # compute enlargement of bounding ellipsoid + ctr = np.mean(self.u[selected,:], axis=0) + var = np.var(self.u[selected,:], axis=0) + # compute expansion factor + f = np.sum((self.u[~selected,:] - ctr.reshape((1, -1)))**2 / var, axis=0).max() + assert np.isfinite(f), (self.u, ctr, var, self.unormed, f) + if not f > 0: + raise np.linalg.LinAlgError("Distances are not positive") + maxf = max(maxf, f) + + assert maxd > 0, (maxd, self.u, self.unormed) + assert maxf > 0, (maxf, self.u, self.unormed) + return maxd, maxf + + class WrappingEllipsoid(object): """Ellipsoid which safely wraps points.""" @@ -1269,13 +1560,19 @@ class WrappingEllipsoid(object): live points """ self.u = u + # allow some parameters to have exactly the same value + # this can occur with grid / categorical parameters + self.variable_dims = np.std(self.u, axis=0) > 0 + if self.variable_dims.all(): + self.variable_dims = Ellipsis def compute_enlargement(self, nbootstraps=50, rng=np.random): """Return ellipsoid enlargement after `nbootstraps` bootstrapping rounds. The wrapping ellipsoid covariance is determined in each bootstrap round. """ - N, ndim = self.u.shape + N = len(self.u) + v = self.u[:,self.variable_dims] selected = np.empty(N, dtype=bool) maxf = 0.0 @@ -1283,8 +1580,8 @@ class WrappingEllipsoid(object): idx = rng.randint(N, size=N) selected[:] = False selected[idx] = True - ua = self.u[selected,:] - ub = self.u[~selected,:] + ua = v[selected,:] + ub = v[~selected,:] # compute enlargement of bounding ellipsoid ctr, cov = bounding_ellipsoid(ua) @@ -1296,14 +1593,14 @@ class WrappingEllipsoid(object): raise np.linalg.LinAlgError("Distances are not positive") maxf = max(maxf, f) - assert maxf > 0, (maxf, self.u) + assert maxf > 0, (maxf, self.u, self.active_dims) return maxf def create_ellipsoid(self, minvol=0.0): """Create wrapping ellipsoid and store its center and covariance.""" assert self.enlarge is not None # compute enlargement of bounding ellipsoid - ctr, cov = bounding_ellipsoid(self.u, minvol=minvol) + ctr, cov = bounding_ellipsoid(self.u[:,self.variable_dims], minvol=minvol) a = np.linalg.inv(cov) self.ellipsoid_center = ctr @@ -1314,6 +1611,19 @@ class WrappingEllipsoid(object): self.ellipsoid_axlens = 1. / np.sqrt(l) self.ellipsoid_axes = np.dot(v, np.diag(self.ellipsoid_axlens)) + def update_center(self, ctr): + """Update ellipsoid center, considering fixed dimensions. + + Parameters + ---------- + ctr: vector + new center + + """ + if self.variable_dims is Ellipsis: + self.ellipsoid_center = ctr + else: + self.ellipsoid_center = ctr[self.variable_dims] def inside(self, u): """Check if inside wrapping ellipsoid. @@ -1329,4 +1639,11 @@ class WrappingEllipsoid(object): True if inside wrapping ellipsoid, for each point in `pts`. """ - return _inside_ellipsoid(u, self.ellipsoid_center, self.ellipsoid_invcov, self.enlarge) + # check the variable subspace with the ellipsoid + inside_variable = _inside_ellipsoid(u[:,self.variable_dims], self.ellipsoid_center, self.ellipsoid_invcov, self.enlarge) + if self.variable_dims is Ellipsis: + return inside_variable + else: + # the remaining dims must be exactly equal + inside_fixed = np.all(self.u[0, ~self.variable_dims] == u[:,~self.variable_dims], axis=1) + return np.logical_and(inside_fixed, inside_variable) diff --git a/ultranest/netiter.py b/ultranest/netiter.py index 46be9f82..d9407cdc 100644 --- a/ultranest/netiter.py +++ b/ultranest/netiter.py @@ -1,6 +1,10 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -"""Functions and classes for treating nested sampling exploration as a tree. +# noqa: D400 D205 +""" +Graph-based nested sampling +--------------------------- + +A formulation of nested sampling exploration as a tree, presented in +section 3.4 of Buchner (2023, https://arxiv.org/abs/2101.09675). The root represents the prior volume, branches and sub-branches split the volume. The leaves of the tree are the integration tail. @@ -15,40 +19,36 @@ The exploration is bootstrap-capable without requiring additional computational effort: The roots are indexed, and the bootstrap explorer can ignore the rootids it does not know about. - - """ -from __future__ import print_function, division -import numpy as np -from numpy import log, log1p, exp, logaddexp import math import sys -from .utils import resample_equal + +import numpy as np +from numpy import exp, log, log1p, logaddexp + from .ordertest import UniformOrderAccumulator +from .utils import resample_equal -class TreeNode(object): +class TreeNode: """Simple tree node.""" def __init__(self, value=None, id=None, children=None): - """Define TreeNode. + """Initialise. Parameters ---------- - value: - used to order nodes + value: float + value used to order nodes (typically log-likelihood) id: int - refers to the order of discovery and storage (PointPile) - children: list of :class:TreeNode objects - children nodes. if None, a empty list is used. + identifier, refers to the order of discovery and storage (PointPile) + children: list + children nodes, should be :py:class:`TreeNode` objects. if None, a empty list is used. """ self.value = value self.id = id - if children is None: - self.children = [] - else: - self.children = children + self.children = children or [] def __str__(self, indent=0): """Visual representation of the node and its children (recursive).""" @@ -60,7 +60,7 @@ def __lt__(self, other): return self.value < other.value -class BreadthFirstIterator(object): +class BreadthFirstIterator: """Generator exploring the tree. Nodes are ordered by value and expanded in order. @@ -125,9 +125,9 @@ def expand_children_of(self, rootid, node): Parameters ---------- rootid: int - index of the root returned by the most recent call to :pyfunc:BreadthFirstIterator.next_node - node: :pyclass:TreeNode - node returned by the most recent call to :pyfunc:BreadthFirstIterator.next_node + index of the root returned by the most recent call to :py:meth:`BreadthFirstIterator.next_node` + node: :py:class:`TreeNode` + node returned by the most recent call to :py:meth:`BreadthFirstIterator.next_node` """ # print("replacing %.1f" % node.value, len(node.children)) i = self.next_index @@ -171,8 +171,10 @@ def print_tree(roots, title='Tree:'): Parameters ---------- - roots: list of :pyclass:TreeNode - tree + roots: list + list of :py:class:`TreeNode` specifying the roots of the tree. + title: str + Print this string first. """ print() print(title) @@ -202,14 +204,14 @@ def print_tree(roots, title='Tree:'): lanes[laneid] = node.children[0] else: # expand width: - for j, child in enumerate(node.children): + for j, _child in enumerate(node.children): rightstr2 = _stringify_lanes(lanes[laneid + 1:], char='\\') if len(rightstr2) != 0: sys.stdout.write(leftstr + '║' + ' ' * j + rightstr2 + "\n") sys.stdout.write(leftstr + '╠' + '╦' * (nchildren - 2) + '╗' + rightstr + "\n") lanes.pop(laneid) - for j, child in enumerate(node.children): + for child in node.children: lanes.insert(laneid, child) explorer.expand_children_of(rootid, node) lastlane = laneid @@ -222,9 +224,9 @@ def dump_tree(filename, roots, pointpile): ---------- filename: str output filename - roots: list of :pyclass:TreeNode - tree to store - pointpile: :class:PointPile + roots: list + list of :py:class:`TreeNode` specifying the roots of the tree. + pointpile: :py:class:`PointPile` information on the node points """ import h5py @@ -259,11 +261,11 @@ def count_tree(roots): Parameters ---------- - roots: list of :pyclass:TreeNode - tree + roots: list + list of :py:class:`TreeNode` specifying the roots of the tree. Returns - -------- + ------- count: int total number of nodes maxwidth: int @@ -291,15 +293,15 @@ def count_tree_between(roots, lo, hi): Parameters ---------- - roots: list of :pyclass:TreeNode - tree + roots: list + list of :py:class:`TreeNode` specifying the roots of the tree. lo: float lower value threshold hi: float upper value threshold Returns - -------- + ------- nnodes: int total number of nodes in the value interval lo .. hi (inclusive). maxwidth: int @@ -335,13 +337,13 @@ def find_nodes_before(root, value): Parameters ---------- - root: :pyclass:TreeNode + root: :py:class:`TreeNode` tree value: float selection threshold Returns - -------- + ------- list_of_parents: list of nodes parents list_of_nforks: list of floats @@ -381,12 +383,12 @@ def find_nodes_before(root, value): return parents, parent_weights -class PointPile(object): +class PointPile: """A in-memory linearized storage of point coordinates. - :pyclass:TreeNodes only store the logL value and id, + :py:class:`TreeNode` objects only store the logL value and id, which is the index in the point pile. The point pile stores - the point coordinates. + the point coordinates in u and p-space (transformed and untransformed). """ def __init__(self, udim, pdim, chunksize=1000): @@ -413,14 +415,14 @@ def add(self, newpointu, newpointp): """Save point. Parameters - ----------- + ---------- newpointu: array point (in u-space) newpointp: array point (in p-space) Returns - --------- + ------- index: int index of the new point in the pile """ @@ -446,7 +448,7 @@ def make_node(self, value, u, p): """Store point in pile, and create a new tree node that points to it. Parameters - ----------- + ---------- value: float value to store in node (loglikelihood) u: array @@ -455,26 +457,25 @@ def make_node(self, value, u, p): point (in p-space) Returns - --------- - node: :pyclass:TreeNode + ------- + node: :py:class:`TreeNode` node """ index = self.add(u, p) return TreeNode(value=value, id=index) -class SingleCounter(object): +class SingleCounter: """Evidence log(Z) and posterior weight summation for a Nested Sampling tree.""" def __init__(self, random=False): - """Initialise counter. + """Initialise. Parameters ---------- random: bool if False, use mean estimator for volume shrinkage if True, draw a random sample - """ self.reset() self.random = random @@ -567,8 +568,8 @@ def passing_node(self, node, parallel_nodes): self.logVolremaining += log1p(-1.0 / nlive) -class MultiCounter(object): - """Like SingleCounter, but bootstrap capable. +class MultiCounter: + """Like :py:class:`SingleCounter`, but bootstrap capable. **Attributes**: @@ -601,7 +602,8 @@ def __init__(self, nroots, nbootstraps=10, random=False, check_insertion_order=F random: bool if False, use mean estimator for volume shrinkage if True, draw a random sample - + check_insertion_order: bool + whether to run insertion order rank U test """ allyes = np.ones(nroots, dtype=bool) # the following is a masked array of size (nbootstraps+1, nroots) @@ -610,7 +612,7 @@ def __init__(self, nroots, nbootstraps=10, random=False, check_insertion_order=F self.rootids = [allyes] self.insertion_order_sample = [] # np.random.seed(1) - for i in range(nbootstraps): + for _i in range(nbootstraps): mask = ~allyes rootids = np.unique(np.random.randint(nroots, size=nroots)) mask[rootids] = True @@ -626,7 +628,13 @@ def __init__(self, nroots, nbootstraps=10, random=False, check_insertion_order=F self.reset(len(self.rootids)) def reset(self, nentries): - """Reset counters/integrator.""" + """Reset counters/integrator. + + Parameters + ---------- + nentries: int + number of iterators + """ self.logweights = [] self.istail = [] self.logZ = -np.inf @@ -719,9 +727,9 @@ def passing_node(self, rootid, node, rootids, parallel_values): Parameters ---------- - rootid: :pyclass:TreeNode + rootid: :py:class:`TreeNode` root node this `node` is from. - node: :pyclass:TreeNode + node: :py:class:`TreeNode` node being processed. rootids: array of ints for each parallel node, which root it belongs to. @@ -856,15 +864,15 @@ def combine_results(saved_logl, saved_nodeids, pointpile, main_iterator, mpi_com loglikelihoods of dead points saved_nodeids: list of ints indices of dead points - pointpile: :pyclass:PointPile + pointpile: :py:class:`PointPile` Point pile. - main_iterator: :pyclass:BreadthFirstIterator + main_iterator: :py:class:`BreadthFirstIterator` iterator used - mpi_comm: + mpi_comm: None | object MPI communicator object, or None if MPI is not used. Returns - -------- + ------- results: dict All information of the run. Important keys: Number of nested sampling iterations (niter), @@ -971,9 +979,9 @@ def logz_sequence(root, pointpile, nbootstraps=12, random=True, onNode=None, ver Parameters ---------- - root: :pyclass:TreeNode + root: :py:class:`TreeNode` Tree - pointpile: :pyclass:PointPile + pointpile: :py:class:`PointPile` Point pile nbootstraps: int Number of independent iterators @@ -988,9 +996,9 @@ def logz_sequence(root, pointpile, nbootstraps=12, random=True, onNode=None, ver Whether to perform a rolling insertion order rank test Returns - -------- + ------- results: dict - Run information, see :pyfunc:combine_results + Run information, see :py:func:`combine_results` sequence: dict Each entry of the dictionary is results['niter'] long, and contains the state of information at that iteration. @@ -1040,13 +1048,15 @@ def logz_sequence(root, pointpile, nbootstraps=12, random=True, onNode=None, ver # first time they are all the same logzerr.append(main_iterator.logZerr_bs) - if len(np.unique(active_values)) == len(active_values) and len(node.children) > 0: + nactive = len(active_values) + + if len(np.unique(active_values)) == nactive and len(node.children) > 0: child_insertion_order = (active_values > node.children[0].value).sum() - insert_order.append(2 * (child_insertion_order + 1.) / len(active_values)) + insert_order.append(2 * (child_insertion_order + 1.) / nactive) else: insert_order.append(np.nan) - nlive.append(len(active_values)) + nlive.append(nactive) logvol.append(main_iterator.logVolremaining) niter += 1 diff --git a/ultranest/ordertest.py b/ultranest/ordertest.py index 06df14de..866847bc 100644 --- a/ultranest/ordertest.py +++ b/ultranest/ordertest.py @@ -1,7 +1,10 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- +# noqa: D400 D205 """ -Mann-Whitney-Wilcoxon U test for a uniform distribution of integers. +U test for a uniform distribution of integers +--------------------------------------------- + +A test for biased nested sampling, presented in +section 4.5.2 of Buchner (2023, https://arxiv.org/abs/2101.09675). This implements the same idea as https://arxiv.org/abs/2006.03371 except their KS test is problematic because the variable (insertion order) @@ -19,7 +22,7 @@ """ -from __future__ import print_function, division +from __future__ import division, print_function __all__ = ['infinite_U_zscore', 'UniformOrderAccumulator'] @@ -30,10 +33,10 @@ def infinite_U_zscore(sample, B): Parameters ---------- - B: int - maximum rank allowed. sample: array of integers values between 0 and B (inclusive). + B: int + maximum rank allowed. Returns ------- @@ -44,9 +47,13 @@ def infinite_U_zscore(sample, B): class UniformOrderAccumulator(): - """Mann-Whitney-Wilcoxon U test accumulator. + """U test accumulator. Stores rank orders (1 to N), for comparison with a uniform order. + + See section 4.5.2 of Buchner (2023, https://arxiv.org/abs/2101.09675), + based on the Mann-Whitney-Wilcoxon U test against a uniform integer + distribution. """ def __init__(self): @@ -65,10 +72,10 @@ def add(self, order, N): Parameters ---------- - N: int - maximum rank allowed. order: int rank between 0 and N (inclusive). + N: int + maximum rank allowed. """ if not 0 <= order <= N: raise ValueError("order %d out of %d invalid" % (order, N)) diff --git a/ultranest/pathsampler.py b/ultranest/pathsampler.py index 5ec13049..b0aaf553 100644 --- a/ultranest/pathsampler.py +++ b/ultranest/pathsampler.py @@ -3,16 +3,17 @@ These features are experimental. """ -import numpy as np - import matplotlib.pyplot as plt +import numpy as np -from ultranest.samplingpath import SamplingPath, ContourSamplingPath, extrapolate_ahead -from ultranest.stepsampler import StepSampler -from ultranest.stepsampler import generate_region_oriented_direction, generate_region_random_direction, generate_random_direction - -from ultranest.flatnuts import ClockedStepSampler, ClockedBisectSampler, ClockedNUTSSampler -from ultranest.flatnuts import SingleJumper, DirectJumper, IntervalJumper +from ultranest.flatnuts import (ClockedBisectSampler, ClockedNUTSSampler, + ClockedStepSampler, DirectJumper, + IntervalJumper, SingleJumper) +from ultranest.samplingpath import (ContourSamplingPath, SamplingPath, + extrapolate_ahead) +from ultranest.stepsampler import (StepSampler, generate_random_direction, + generate_region_oriented_direction, + generate_region_random_direction) class SamplingPathSliceSampler(StepSampler): @@ -172,13 +173,12 @@ def __init__(self, nresets, nsteps, scale=1.0, balance=0.01, nudge=1.1, log=Fals def __str__(self): """Get string representation.""" - return '(nsteps=%d, nresets=%d, AR=%d%%)' % ( + return '%s(nsteps=%d, nresets=%d, AR=%d%%)' % ( type(self).__name__, self.nsteps, self.nresets, (1 - self.balance) * 100) def start(self): """Start sampler, reset all counters.""" if hasattr(self, 'naccepts') and self.nrejects + self.naccepts > 0: - nr, na = self.nrejects, self.naccepts self.logstat.append([ self.naccepts / (self.nrejects + self.naccepts), self.nreflects / (self.nreflects + self.nrejects + self.naccepts), @@ -243,7 +243,7 @@ def set_gradient(self, grad_function): print("set gradient function to %s" % grad_function.__name__) def plot_gradient_wrapper(x, plot=False): - """wrapper that makes plots (when desired)""" + """Make plot while computing gradient (optionally).""" v = grad_function(x) if plot: plt.plot(x[0], x[1], '+ ', color='k', ms=10) diff --git a/ultranest/plot.py b/ultranest/plot.py index 09af78a1..2b5cc589 100644 --- a/ultranest/plot.py +++ b/ultranest/plot.py @@ -1,24 +1,27 @@ -"""Plotting utilities.""" +# noqa: D400 D205 +""" +Plotting utilities +------------------ -from __future__ import (print_function, division) -from six.moves import range +""" + +from __future__ import division, print_function import logging import types import warnings -import numpy as np import matplotlib.pyplot as pl -from matplotlib.ticker import MaxNLocator, NullLocator -# from matplotlib.colors import LinearSegmentedColormap, colorConverter -from matplotlib.ticker import ScalarFormatter - -import scipy.stats import matplotlib.pyplot as plt import numpy +import numpy as np +import scipy.stats +# from matplotlib.colors import LinearSegmentedColormap, colorConverter +from matplotlib.ticker import MaxNLocator, NullLocator, ScalarFormatter +from six.moves import range -from .utils import resample_equal from .utils import quantile as _quantile +from .utils import resample_equal try: str_type = types.StringTypes @@ -34,14 +37,69 @@ __all__ = ["runplot", "cornerplot", "traceplot", "PredictionBand"] -def cornerplot(results, logger=None): - """Make a corner plot with corner.""" +def cornerplot( + results, min_weight=1e-4, with_legend=True, logger=None, + levels=[0.9973, 0.9545, 0.6827, 0.3934], + plot_datapoints=False, plot_density=False, show_titles=True, quiet=True, + contour_kwargs=dict(linestyles=['-','-.',':','--'], colors=['navy','navy','navy','purple']), + color='purple', quantiles=[0.15866, 0.5, 0.8413], **corner_kwargs +): + """Make a healthy corner plot with corner. + + Essentially does:: + + paramnames = results['paramnames'] + data = results['weighted_samples']['points'] + weights = results['weighted_samples']['weights'] + + return corner.corner( + results['weighted_samples']['points'], + weights=results['weighted_samples']['weights'], + labels=results['paramnames']) + + Parameters + ---------- + results: dict + data dictionary + min_weight: float + cut off low-weight posterior points. Avoids meaningless + stragglers when plot_datapoints is True. + with_legend: bool + whether to add a legend to show meaning of the lines. + logger: None | object + where to log + levels: list + list of credible interval levels + plot_datapoints : bool + Draw individual data points. + plot_density : bool + Draw the density colormap. + show_titles : bool + Displays a title above each 1-D histogram showing the 0.5 quantile + with the upper and lower errors supplied by the quantiles argument. + quiet : bool + If true, suppress warnings for small datasets. + contour_kwargs : dict + Any additional keyword arguments to pass to the `contour` method. + color : str + ``matplotlib`` style color for all histograms. + quantiles: list + fractional quantiles to show on the 1-D histograms as vertical dashed lines. + **corner_kwargs: dict + Any remaining keyword arguments are sent to :func:`corner.corner`. + + Returns + ------- + fig : `~matplotlib.figure.Figure` + The ``matplotlib`` figure instance for the corner plot. + + """ paramnames = results['paramnames'] data = np.array(results['weighted_samples']['points']) weights = np.array(results['weighted_samples']['weights']) cumsumweights = np.cumsum(weights) - mask = cumsumweights > 1e-4 + mask = cumsumweights > min_weight if mask.sum() == 1: if logger is not None: @@ -57,23 +115,129 @@ def cornerplot(results, logger=None): # monkey patch to disable a useless warning oldfunc = logging.warning logging.warning = lambda *args, **kwargs: None - corner.corner(data[mask,:], weights=weights[mask], - labels=paramnames, show_titles=True) + fig = corner.corner( + data[mask,:], weights=weights[mask], + labels=paramnames, show_titles=show_titles, quiet=quiet, + plot_datapoints=plot_datapoints, plot_density=plot_density, + levels=levels, quantiles=quantiles, + contour_kwargs=contour_kwargs, color=color, **corner_kwargs + ) + # Create legend handles + if with_legend and data.shape[1] > 1: + legend_handles = [ + plt.Line2D( + [0], [0], linestyle='--', color=color, + label='%.1f%% marginal' % (100 * (quantiles[-1] - quantiles[0]))), + ] + [plt.Line2D( + [0], [0], linestyle=ls, color=linecolor, + label='%.1f%%' % (100 * level)) + for ls, linecolor, level in zip( + contour_kwargs.get('linestyles', [])[::-1], + contour_kwargs.get('colors', [color] * 100)[::-1], + levels[::-1]) + ] + if len(legend_handles) == len(levels) + 1 and len(legend_handles) > 0: + plt.legend( + title='credible prob level', + handles=legend_handles, + loc='lower right', bbox_to_anchor=(1.01,1.2), frameon=False + ) logging.warning = oldfunc + return fig + +def highest_density_interval_from_samples(xsamples, xlo=None, xhi=None, probability_level=0.68): + """ + Compute the highest density interval (HDI) from posterior samples. -class PredictionBand(object): + Parameters + ---------- + xsamples : array_like + The posterior samples from which to compute the HDI. + xlo : float or None, optional + Lower boundary limiting the space. Default is None. + xhi : float or None, optional + Upper boundary limiting the space. Default is None. + probability_level : float, optional + The desired probability level for the HDI. Default is 0.68. + + Returns + ------- + x_MAP: float + maximum a posteriori (MAP) estimate. + xerrlo: float + lower uncertainty (lower HDI bound minus x_MAP). + xerrhi: float + upper uncertainty (x_MAP minus upper HDI bound). + + Notes + ----- + The function starts at the highest density point and accumulates neighboring points + until the specified probability level is reached. If `xlo` or `xhi` is provided, + the HDI is constrained within these bounds. + + Requires getdist to be installed for a kernel density estimation. + + For uniform distributions, this function will give unpredictable results for the MAP. + + Examples + -------- + >>> xsamples = np.random.normal(loc=0, scale=1, size=100000) + >>> hdi = highest_density_interval_from_samples(xsamples) + >>> print('x = %.1f + %.2f - %.2f' % hdi) + x = 0.0 + 1.02 - 0.96 + """ + import getdist.chains + from getdist.mcsamples import MCSamples + getdist.chains.print_load_details = False + samples = MCSamples( + samples=xsamples, names=['x'], ranges={'x':[xlo,xhi]}, + settings=dict(mult_bias_correction_order=1)) + samples.raise_on_bandwidth_errors = True + density_bounded = samples.get1DDensityGridData('x') + + x = density_bounded.x + y = density_bounded.P / np.sum(density_bounded.P) + + # Sort the y values in descending order + sorted_indices = np.argsort(y)[::-1] + + # define MAP as the peak. This works well if the peak is declining to both sides + MAP = x[sorted_indices[0]] + total_probability = y[sorted_indices[0]] + i_lo = sorted_indices[0] + i_hi = sorted_indices[0] + for i in sorted_indices[1:]: + # Add the current probability to the total + i_lo = min(i_lo, i) + i_hi = max(i_hi, i) + total_probability = y[i_lo:i_hi + 1].sum() + # Check if the total probability exceeds or equals the desired level + if total_probability >= probability_level: + break + + x_lo = x[i_lo] + x_hi = x[i_hi] + return MAP, MAP - x_lo, x_hi - MAP + + +class PredictionBand: """Plot bands of model predictions as calculated from a chain. call add(y) to add predictions from each chain point - Example:: + .. testsetup:: + + import numpy + chain = numpy.random.uniform(size=(20, 2)) + + .. testcode:: x = numpy.linspace(0, 1, 100) band = PredictionBand(x) for c in chain: band.add(c[0] * x + c[1]) - # add median line + # add median line. As an option a matplotlib ax can be given. band.line(color='k') # add 1 sigma quantile band.shade(color='k', alpha=0.3) @@ -81,15 +245,21 @@ class PredictionBand(object): band.shade(q=0.01, color='gray', alpha=0.1) plt.show() - Parameters - ---------- - x: array - The independent variable - + To plot onto a specific axis, use `band.line(..., ax=myaxis)`. """ def __init__(self, x, shadeargs={}, lineargs={}): - """Initialise with independent variable *x*.""" + """Initialise. + + Parameters + ---------- + x: array + Independent variable. + shadeargs: dict + default arguments for shade function. + lineargs: dict + default arguments for line function. + """ self.x = x self.ys = [] self.shadeargs = shadeargs @@ -114,22 +284,26 @@ def get_line(self, q=0.5): assert len(self.ys) > 0, self.ys return scipy.stats.mstats.mquantiles(self.ys, q, axis=0)[0] - def shade(self, q=0.341, **kwargs): - """Plot a shaded region between 0.5-q and 0.5+q. Default is 1 sigma.""" + def shade(self, q=0.341, ax=None, **kwargs): + """Plot a shaded region between 0.5-q and 0.5+q, by default 1 sigma.""" if not 0 <= q <= 0.5: - raise ValueError("quantile distance from the median, q, must be between 0 and 0.5, not %s. For a 99% quantile range, use q=0.48." % q) + raise ValueError("quantile distance from the median, q, must be between 0 and 0.5, not %s. For a 99%% quantile range, use q=0.48." % q) shadeargs = dict(self.shadeargs) shadeargs.update(kwargs) lo = self.get_line(0.5 - q) hi = self.get_line(0.5 + q) - return plt.fill_between(self.x, lo, hi, **shadeargs) + if ax is None: + ax = plt + return ax.fill_between(self.x, lo, hi, **shadeargs) - def line(self, **kwargs): + def line(self, ax=None, **kwargs): """Plot the median curve.""" lineargs = dict(self.lineargs) lineargs.update(kwargs) mid = self.get_line(0.5) - return plt.plot(self.x, mid, **lineargs) + if ax is None: + ax = plt + return ax.plot(self.x, mid, **lineargs) # the following function is taken from https://github.com/joshspeagle/dynesty/blob/master/dynesty/plotting.py @@ -254,7 +428,8 @@ def runplot(results, span=None, logplot=False, kde=True, nkde=1000, else: warnings.warn("The number of iterations and samples differ " "by an amount that isn't the number of final " - "live points. `mark_final_live` has been disabled.") + "live points. `mark_final_live` has been disabled.", + stacklevel=3) mark_final_live = False # Determine plotting bounds for each subplot. @@ -265,6 +440,7 @@ def runplot(results, span=None, logplot=False, kde=True, nkde=1000, try: # from scipy.ndimage import gaussian_filter as norm_kde from scipy.stats import gaussian_kde + # Derive kernel density estimate. wt_kde = gaussian_kde(resample_equal(-logvol, weights)) # KDE logvol_new = np.linspace(logvol[0], logvol[-1], nkde) # resample @@ -283,7 +459,7 @@ def runplot(results, span=None, logplot=False, kde=True, nkde=1000, for i, _ in enumerate(span): try: ymin, ymax = span[i] - except: + except Exception: span[i] = (max(data[i]) * span[i], max(data[i])) if lnz_error and no_span: if logplot: @@ -302,7 +478,7 @@ def runplot(results, span=None, logplot=False, kde=True, nkde=1000, fig, axes = fig try: axes.reshape(4, 1) - except: + except Exception: raise ValueError("Provided axes do not match the required shape " "for plotting samples.") # If figure is provided, keep previous bounds if they were larger. @@ -557,6 +733,7 @@ def traceplot(results, span=None, quantiles=[0.025, 0.5, 0.975], smooth=0.02, try: from scipy.ndimage import gaussian_filter as norm_kde from scipy.stats import gaussian_kde + # Derive kernel density estimate. wt_kde = gaussian_kde(resample_equal(-logvol, weights)) # KDE logvol_grid = np.linspace(logvol[0], logvol[-1], nkde) # resample @@ -594,12 +771,12 @@ def traceplot(results, span=None, quantiles=[0.025, 0.5, 0.975], smooth=0.02, try: samples_id = results['samples_id'] uid = np.unique(samples_id) - except: + except Exception: raise ValueError("Sample IDs are not defined!") try: ids = connect_highlight[0] ids = connect_highlight - except: + except Exception: ids = np.random.choice(uid, size=connect_highlight, replace=False) # Determine plotting bounds for marginalized 1-D posteriors. @@ -611,7 +788,7 @@ def traceplot(results, span=None, quantiles=[0.025, 0.5, 0.975], smooth=0.02, for i, _ in enumerate(span): try: xmin, xmax = span[i] - except: + except Exception: q = [0.5 - 0.5 * span[i], 0.5 + 0.5 * span[i]] span[i] = _quantile(samples[i], q, weights=weights) @@ -620,7 +797,7 @@ def traceplot(results, span=None, quantiles=[0.025, 0.5, 0.975], smooth=0.02, labels = [r"$x_{%d}$" % (i + 1) for i in range(ndim)] # Setting up smoothing. - if (isinstance(smooth, int_type) or isinstance(smooth, float_type)): + if (isinstance(smooth, int_type) or isinstance(smooth, float_type)): # noqa: SIM101 smooth = [smooth for i in range(ndim)] # Setting up default plot layout. @@ -630,7 +807,7 @@ def traceplot(results, span=None, quantiles=[0.025, 0.5, 0.975], smooth=0.02, fig, axes = fig try: axes.reshape(ndim, 2) - except: + except Exception: raise ValueError("Provided axes do not match the required shape " "for plotting samples.") @@ -683,7 +860,7 @@ def traceplot(results, span=None, quantiles=[0.025, 0.5, 0.975], smooth=0.02, try: [ax.axhline(t, color=truth_color, **truth_kwargs) for t in truths[i]] - except: + except Exception: ax.axhline(truths[i], color=truth_color, **truth_kwargs) # Plot marginalized 1-D posterior. @@ -752,7 +929,7 @@ def traceplot(results, span=None, quantiles=[0.025, 0.5, 0.975], smooth=0.02, try: [ax.axvline(t, color=truth_color, **truth_kwargs) for t in truths[i]] - except: + except Exception: ax.axvline(truths[i], color=truth_color, **truth_kwargs) # Set titles. if show_titles: diff --git a/ultranest/popstepsampler.py b/ultranest/popstepsampler.py new file mode 100644 index 00000000..420d46ae --- /dev/null +++ b/ultranest/popstepsampler.py @@ -0,0 +1,1008 @@ +# noqa: D400 D205 +""" +Vectorized step samplers +------------------------ + +Likelihood based on GPUs (model emulators based on neural networks, +or JAX implementations) can evaluate hundreds of points as efficiently +as one point. The implementations in this module leverage this power, +by providing random walks of populations of walkers. +""" + +import numpy as np +import scipy.stats + +from ultranest.stepfuncs import (evolve, generate_cube_oriented_direction, + generate_cube_oriented_direction_scaled, + generate_differential_direction, + generate_mixture_random_direction, + generate_random_direction, + generate_region_oriented_direction, + generate_region_random_direction, int_dtype, + step_back, update_vectorised_slice_sampler) +from ultranest.utils import submasks + + +def unitcube_line_intersection(ray_origin, ray_direction): + r"""Compute intersection of a line (ray) and a unit box (0:1 in all axes). + + Based on + http://www.iquilezles.org/www/articles/intersectors/intersectors.htm + + Parameters + ----------- + ray_origin: array of vectors + starting point of line + ray_direction: vector + line direction vector + + Returns + -------- + tleft: array + negative intersection point distance from ray\_origin in units in ray\_direction + tright: array + positive intersection point distance from ray\_origin in units in ray\_direction + + """ + # make sure ray starts inside the box + assert (ray_origin >= 0).all(), ray_origin + assert (ray_origin <= 1).all(), ray_origin + assert ((ray_direction**2).sum()**0.5 > 1e-200).all(), ray_direction + + # step size + with np.errstate(divide='ignore', invalid='ignore'): + m = 1. / ray_direction + n = m * (ray_origin - 0.5) + k = np.abs(m) * 0.5 + # line coordinates of intersection + # find first intersecting coordinate + t1 = -n - k + t2 = -n + k + return np.nanmax(t1, axis=1), np.nanmin(t2, axis=1) + + +def diagnose_move_distances(region, ustart, ufinal): + """Compare random walk travel distance to MLFriends radius. + + Compares in whitened space (t-space), the L2 norm between final + point and starting point to the MLFriends bootstrapped radius. + + Parameters + ---------- + region: MLFriends + built region + ustart: array + starting positions + ufinal: array + final positions + + Returns + ------- + far_enough: bool + whether the distance is larger than the radius + move_distance: float + distance between start and final point in whitened space + reference_distance: float + MLFriends radius + """ + assert ustart.shape == ufinal.shape, (ustart.shape, ufinal.shape) + tstart = region.transformLayer.transform(ustart) + tfinal = region.transformLayer.transform(ufinal) + d2 = ((tstart - tfinal)**2).sum(axis=1) + far_enough = d2 > region.maxradiussq + + return far_enough, [d2**0.5, region.maxradiussq**0.5] + + +class GenericPopulationSampler(): + def plot(self, filename): + """Plot sampler statistics. + + Parameters + ----------- + filename: str + Stores plot into ``filename`` and data into + ``filename + ".txt.gz"``. + """ + if len(self.logstat) == 0: + return + + import matplotlib.pyplot as plt + plt.figure(figsize=(10, 1 + 3 * len(self.logstat_labels))) + for i, label in enumerate(self.logstat_labels): + part = [entry[i] for entry in self.logstat] + plt.subplot(len(self.logstat_labels), 1, 1 + i) + plt.ylabel(label) + plt.plot(part) + x = [] + y = [] + for j in range(0, len(part), 20): + x.append(j) + y.append(np.mean(part[j:j + 20])) + plt.plot(x, y) + if np.min(part) > 0: + plt.yscale('log') + plt.savefig(filename, bbox_inches='tight') + np.savetxt(filename + '.txt.gz', self.logstat, + header=','.join(self.logstat_labels), delimiter=',') + plt.close() + + @property + def mean_jump_distance(self): + """Geometric mean jump distance.""" + if len(self.logstat) == 0: + return np.nan + return np.exp(np.average( + np.log([entry[-1] + 1e-10 for entry in self.logstat]), + weights=([entry[0] for entry in self.logstat]) + )) + + @property + def far_enough_fraction(self): + """Fraction of jumps exceeding reference distance.""" + if len(self.logstat) == 0: + return np.nan + return np.average( + [entry[-2] for entry in self.logstat], + weights=([entry[0] for entry in self.logstat]) + ) + + def get_info_dict(self): + return dict( + num_logs=len(self.logstat), + rejection_rate=1 - np.nanmean([entry[0] for entry in self.logstat]) if len(self.logstat) > 0 else np.nan, + mean_scale=np.nanmean([entry[1] for entry in self.logstat]) if len(self.logstat) > 0 else np.nan, + mean_nsteps=np.nanmean([entry[2] for entry in self.logstat]) if len(self.logstat) > 0 else np.nan, + mean_distance=self.mean_jump_distance, + frac_far_enough=self.far_enough_fraction, + last_logstat=dict(zip(self.logstat_labels, self.logstat[-1] if len(self.logstat) > 1 else [np.nan] * len(self.logstat_labels))) + ) + + def print_diagnostic(self): + """Print diagnostic of step sampler performance.""" + if len(self.logstat) == 0: + print("diagnostic unavailable, no recorded steps found") + return + frac_farenough = self.far_enough_fraction + average_distance = self.mean_jump_distance + if frac_farenough < 0.5: + advice = ': very fishy. Double nsteps and see if fraction and lnZ change)' + elif frac_farenough < 0.66: + advice = ': fishy. Double nsteps and see if fraction and lnZ change)' + else: + advice = ' (should be >50%)' + print('step sampler diagnostic: jump distance %.2f (should be >1), far enough fraction: %.2f%% %s' % ( + average_distance, frac_farenough * 100, advice)) + + def plot_jump_diagnostic_histogram(self, filename, **kwargs): + """Plot jump diagnostic histogram.""" + if len(self.logstat) == 0: + return + import matplotlib.pyplot as plt + plt.hist(np.log10([entry[-1] for entry in self.logstat]), **kwargs) + ylo, yhi = plt.ylim() + plt.vlines(self.mean_jump_distance, ylo, yhi) + plt.ylim(ylo, yhi) + plt.xlabel('log(relative step distance)') + plt.ylabel('Frequency') + plt.savefig(filename, bbox_inches='tight') + plt.close() + + +class PopulationRandomWalkSampler(GenericPopulationSampler): + """Vectorized Gaussian Random Walk sampler.""" + + def __init__( + self, popsize, nsteps, generate_direction, scale, + scale_adapt_factor=0.9, scale_min=1e-20, scale_max=20, log=False, logfile=None + ): + """Initialise. + + Parameters + ---------- + popsize: int + number of walkers to maintain. + this should be fairly large (~100), if too large you probably get memory issues + Also, some results have to be discarded as the likelihood threshold increases. + Observe the nested sampling efficiency. + nsteps: int + number of steps to take until the found point is accepted as independent. + To find the right value, see :py:class:`ultranest.calibrator.ReactiveNestedCalibrator` + generate_direction: function + Function that gives proposal kernel shape, one of: + :py:func:`ultranest.popstepsampler.generate_cube_oriented_direction` + :py:func:`ultranest.popstepsampler.generate_cube_oriented_direction_scaled` + :py:func:`ultranest.popstepsampler.generate_random_direction` + :py:func:`ultranest.popstepsampler.generate_region_oriented_direction` + :py:func:`ultranest.popstepsampler.generate_region_random_direction` + scale: float + initial guess for the proposal scaling factor + scale_adapt_factor: float + if 1, no adapting is done. + if <1, the scale is increased if the acceptance rate is below 23.4%, + or decreased if it is above, by *scale_adapt_factor*. + scale_min: float + lowest value allowed for scale, do not adapt down further + scale_max: float + highest value allowed for scale, do not adapt up further + logfile: file + where to print the current scaling factor and acceptance rate + + """ + self.nsteps = nsteps + self.nrejects = 0 + self.scale = scale + self.ncalls = 0 + assert scale_adapt_factor <= 1 + self.scale_adapt_factor = scale_adapt_factor + self.scale_min = scale_min + self.scale_max = scale_max + + self.log = log + self.logfile = logfile + self.logstat = [] + self.logstat_labels = ['accept_rate', 'efficiency', 'scale', 'far_enough', 'mean_rel_jump'] + self.prepared_samples = [] + + self.popsize = popsize + self.generate_direction = generate_direction + + def __str__(self): + """Return string representation.""" + return 'PopulationRandomWalkSampler(popsize=%d, nsteps=%d, generate_direction=%s, scale=%.g)' % ( + self.popsize, self.nsteps, self.generate_direction, self.scale) + + def region_changed(self, Ls, region): + """Act upon region changed. Currently unused.""" + pass + + def __next__( + self, region, Lmin, us, Ls, transform, loglike, ndraw=10, + plot=False, tregion=None, log=False + ): + """Sample a new live point. + + Parameters + ---------- + region: MLFriends object + Region + Lmin: float + current log-likelihood threshold + us: np.array((nlive, ndim)) + live points + Ls: np.array(nlive) + loglikelihoods live points + transform: function + prior transform function + loglike: function + loglikelihood function + ndraw: int + not used + plot: bool + not used + tregion: bool + not used + log: bool + not used + + Returns + ------- + u: np.array(ndim) or None + new point coordinates (None if not yet available) + p: np.array(nparams) or None + new point transformed coordinates (None if not yet available) + L: float or None + new point likelihood (None if not yet available) + nc: int + + """ + nlive, ndim = us.shape + + # fill if empty: + if len(self.prepared_samples) == 0: + # choose live points + ilive = np.random.randint(0, nlive, size=self.popsize) + allu = us[ilive,:] + allp = None + allL = Ls[ilive] + nc = self.nsteps * self.popsize + nrejects_expected = self.nrejects + self.nsteps * self.popsize * (1 - 0.234) + + for _i in range(self.nsteps): + # perturb walker population + v = self.generate_direction(allu, region, self.scale) + # compute intersection of u + t * v with unit cube + tleft, tright = unitcube_line_intersection(allu, v) + proposed_t = scipy.stats.truncnorm.rvs(tleft, tright, loc=0, scale=1).reshape((-1, 1)) + + proposed_u = allu + v * proposed_t + mask_outside = ~np.logical_and(proposed_u > 0, proposed_u < 1).all(axis=1) + assert not mask_outside.any(), proposed_u[mask_outside, :] + + proposed_p = transform(proposed_u) + # accept if likelihood threshold exceeded + proposed_L = loglike(proposed_p) + mask_accept = proposed_L > Lmin + self.nrejects += (~mask_accept).sum() + allu[mask_accept,:] = proposed_u[mask_accept,:] + if allp is None: + del allp + allp = proposed_p * np.nan + allp[mask_accept,:] = proposed_p[mask_accept,:] + allL[mask_accept] = proposed_L[mask_accept] + assert np.isfinite(allp).all(), 'some walkers never moved! Double nsteps of PopulationRandomWalkSampler.' + far_enough, (move_distance, reference_distance) = diagnose_move_distances(region, us[ilive[mask_accept],:], allu[mask_accept,:]) + self.prepared_samples = list(zip(allu, allp, allL)) + + self.logstat.append([ + mask_accept.mean(), + 1 - (self.nrejects - (nrejects_expected - self.nsteps * self.popsize * (1 - 0.234))) / (self.nsteps * self.popsize), + self.scale, + self.nsteps, + np.mean(far_enough), + np.exp(np.mean(np.log(move_distance / reference_distance + 1e-10))) + ]) + if self.logfile: + self.logfile.write("rescale\t%.4f\t%.4f\t%g\t%.4f%g\n" % self.logstat[-1]) + + # adapt slightly + if self.nrejects > nrejects_expected and self.scale > self.scale_min: + # lots of rejects, decrease scale + self.scale *= self.scale_adapt_factor + elif self.nrejects < nrejects_expected and self.scale < self.scale_max: + self.scale /= self.scale_adapt_factor + else: + nc = 0 + + u, p, L = self.prepared_samples.pop(0) + return u, p, L, nc + + +class PopulationSliceSampler(GenericPopulationSampler): + """Vectorized slice/HARM sampler. + + Can revert until all previous steps have likelihoods allL above Lmin. + Updates currentt, generation and allL, in-place. + """ + + def __init__( + self, popsize, nsteps, generate_direction, scale=1.0, + scale_adapt_factor=0.9, log=False, logfile=None + ): + """Initialise. + + Parameters + ---------- + popsize: int + number of walkers to maintain + nsteps: int + number of steps to take until the found point is accepted as independent. + To find the right value, see :py:class:`ultranest.calibrator.ReactiveNestedCalibrator` + generate_direction: function `(u, region, scale) -> v` + function such as `generate_unit_directions`, which + generates a random slice direction. + scale: float + initial guess scale for the length of the slice + scale_adapt_factor: float + smoothing factor for updating scale. + if near 1, scale is barely updating, if near 0, + the last slice length is used as a initial guess for the next. + + """ + self.nsteps = nsteps + self.nrejects = 0 + self.scale = scale + self.scale_adapt_factor = scale_adapt_factor + self.allu = [] + self.allL = [] + self.currentt = [] + self.currentv = [] + self.currentp = [] + self.generation = [] + self.current_left = [] + self.current_right = [] + self.searching_left = [] + self.searching_right = [] + self.ringindex = 0 + + self.log = log + self.logfile = logfile + self.logstat = [] + self.logstat_labels = ['accept_rate', 'efficiency', 'scale', 'far_enough', 'mean_rel_jump'] + + self.popsize = popsize + self.generate_direction = generate_direction + + def __str__(self): + """Return string representation.""" + return 'PopulationSliceSampler(popsize=%d, nsteps=%d, generate_direction=%s, scale=%.g)' % ( + self.popsize, self.nsteps, self.generate_direction, self.scale) + + def region_changed(self, Ls, region): + """Act upon region changed. Currently unused.""" + # self.scale = region.us.std(axis=1).mean() + if self.logfile: + self.logfile.write("region-update\t%g\t%g\n" % (self.scale, region.us.std(axis=1).mean())) + + def _setup(self, ndim): + """Allocate arrays.""" + self.allu = np.zeros((self.popsize, self.nsteps + 1, ndim)) + np.nan + self.allL = np.zeros((self.popsize, self.nsteps + 1)) + np.nan + self.currentt = np.zeros(self.popsize) + np.nan + self.currentv = np.zeros((self.popsize, ndim)) + np.nan + self.generation = np.zeros(self.popsize, dtype=int_dtype) - 1 + self.current_left = np.zeros(self.popsize) + self.current_right = np.zeros(self.popsize) + self.searching_left = np.zeros(self.popsize, dtype=bool) + self.searching_right = np.zeros(self.popsize, dtype=bool) + + def setup_start(self, us, Ls, starting): + """Initialize walker starting points. + + For iteration zero, randomly selects a live point as starting point. + + Parameters + ---------- + us: np.array((nlive, ndim)) + live points + Ls: np.array(nlive) + loglikelihoods live points + starting: np.array(nwalkers, dtype=bool) + which walkers to initialize. + + """ + if self.log: + print("setting up:", starting) + nlive = len(us) + i = np.random.randint(nlive, size=starting.sum()) + + if not starting.all(): + while starting[self.ringindex]: + # if the one we are waiting for is being restarted, + # we may as well pick the next one to wait for + # because every other one is started from a random point + # as well + self.shift() + + self.allu[starting,0] = us[i] + self.allL[starting,0] = Ls[i] + self.generation[starting] = 0 + + @property + def status(self): + """Return compact string representation of the current status.""" + s1 = ('G:' + ''.join(['%d' % g if g >= 0 else '_' for g in self.generation])) + s2 = ('S:' + ''.join([ + 'S' if not np.isfinite(self.currentt[i]) else 'L' if self.searching_left[i] else 'R' if self.searching_right[i] else 'B' + for i in range(self.popsize)])) + return s1 + ' ' + s2 + + def setup_brackets(self, mask_starting, region): + """Pick starting direction and range for slice. + + Parameters + ---------- + mask_starting: np.array(nwalkers, dtype=bool) + which walkers to set up. + region: MLFriends object + Region + + """ + if self.log: + print("starting brackets:", mask_starting) + i_starting, = np.where(mask_starting) + self.current_left[i_starting] = -self.scale + self.current_right[i_starting] = self.scale + self.searching_left[i_starting] = True + self.searching_right[i_starting] = True + self.currentt[i_starting] = 0 + # choose direction for new slice + self.currentv[i_starting,:] = self.generate_direction( + self.allu[i_starting, self.generation[i_starting]], + region) + + def _setup_currentp(self, nparams): + if self.log: + print("setting currentp") + self.currentp = np.zeros((self.popsize, nparams)) + np.nan + + def advance(self, transform, loglike, Lmin, region): + """Advance the walker population. + + Parameters + ---------- + transform: function + prior transform function + loglike: function + loglikelihood function + Lmin: float + current log-likelihood threshold + region: MLFriends object + Region + + Returns + ------- + nc: int + Number of likelihood function calls + """ + movable = self.generation < self.nsteps + all_movable = movable.all() + # print("moving ", movable.sum(), self.popsize) + if all_movable: + i = np.arange(self.popsize) + args = [ + self.allu[i, self.generation], + self.allL[i, self.generation], + # pass values directly + self.currentt, + self.currentv, + self.current_left, + self.current_right, + self.searching_left, + self.searching_right + ] + del i + else: + args = [ + self.allu[movable, self.generation[movable]], + self.allL[movable, self.generation[movable]], + # this makes copies + self.currentt[movable], + self.currentv[movable], + self.current_left[movable], + self.current_right[movable], + self.searching_left[movable], + self.searching_right[movable] + ] + if self.log: + print("evolve will advance:", movable) + + uorig = args[0].copy() + ( + ( + currentt, currentv, + current_left, current_right, searching_left, searching_right + ), + (success, unew, pnew, Lnew), + nc + ) = evolve(transform, loglike, Lmin, *args) + + if success.any(): + far_enough, (move_distance, reference_distance) = diagnose_move_distances(region, uorig[success,:], unew) + self.logstat.append([ + success.mean(), + self.scale, + self.nsteps, + np.mean(far_enough) if len(far_enough) > 0 else 0, + np.exp(np.mean(np.log(move_distance / reference_distance + 1e-10))) if len(far_enough) > 0 else 0 + ]) + if self.logfile: + self.logfile.write("rescale\t%.4f\t%.4f\t%g\t%.4f%g\n" % self.logstat[-1]) + + if self.log: + print("movable", movable.shape, movable.sum(), success.shape) + moved = submasks(movable, success) + if self.log: + print("evolve moved:", moved) + self.generation[moved] += 1 + if len(pnew) > 0: + if len(self.currentp) == 0: + self._setup_currentp(nparams=pnew.shape[1]) + + if self.log: + print("currentp", self.currentp[moved,:].shape, pnew.shape) + self.currentp[moved,:] = pnew + + # update with what we learned + # print(currentu.shape, currentL.shape, success.shape, self.generation[movable]) + self.allu[moved, self.generation[moved]] = unew + self.allL[moved, self.generation[moved]] = Lnew + if all_movable: + # in this case, the values were directly overwritten + pass + else: + self.currentt[movable] = currentt + self.currentv[movable] = currentv + self.current_left[movable] = current_left + self.current_right[movable] = current_right + self.searching_left[movable] = searching_left + self.searching_right[movable] = searching_right + return nc + + def shift(self): + """Update walker from which to pick next.""" + # this is a ring buffer + # shift index forward, wrapping around + # this is better than copying memory around when a element is removed + self.ringindex = (self.ringindex + 1) % self.popsize + + def __next__( + self, region, Lmin, us, Ls, transform, loglike, ndraw=10, + plot=False, tregion=None, log=False + ): + """Sample a new live point. + + Parameters + ---------- + region: MLFriends object + Region + Lmin: float + current log-likelihood threshold + us: np.array((nlive, ndim)) + live points + Ls: np.array(nlive) + loglikelihoods live points + transform: function + prior transform function + loglike: function + loglikelihood function + ndraw: int + not used + plot: bool + not used + tregion: bool + not used + log: bool + not used + + Returns + ------- + u: np.array(ndim) or None + new point coordinates (None if not yet available) + p: np.array(nparams) or None + new point transformed coordinates (None if not yet available) + L: float or None + new point likelihood (None if not yet available) + nc: int + + """ + nlive, ndim = us.shape + # initialize + if len(self.allu) == 0: + self._setup(ndim) + + step_back(Lmin, self.allL, self.generation, self.currentt) + + starting = self.generation < 0 + if starting.any(): + self.setup_start(us[Ls > Lmin], Ls[Ls > Lmin], starting) + assert (self.generation >= 0).all(), self.generation + + # find those where bracket is undefined: + mask_starting = ~np.isfinite(self.currentt) + if mask_starting.any(): + self.setup_brackets(mask_starting, region) + + if self.log: + print(str(self), "(before)") + nc = self.advance(transform, loglike, Lmin, region) + if self.log: + print(str(self), "(after)") + + # harvest top individual if possible + if self.generation[self.ringindex] == self.nsteps: + if self.log: + print("have a candidate") + u, p, L = self.allu[self.ringindex, self.nsteps, :].copy(), self.currentp[self.ringindex, :].copy(), self.allL[self.ringindex, self.nsteps].copy() + assert np.isfinite(u).all(), u + assert np.isfinite(p).all(), p + self.generation[self.ringindex] = -1 + self.currentt[self.ringindex] = np.nan + self.allu[self.ringindex,:,:] = np.nan + self.allL[self.ringindex,:] = np.nan + + # adjust guess length + newscale = (self.current_right[self.ringindex] - self.current_left[self.ringindex]) / 2 + self.scale = self.scale * 0.9 + 0.1 * newscale + + self.shift() + return u, p, L, nc + else: + return None, None, None, nc + + +def slice_limit_to_unitcube(tleft, tright): + """ + Return the slice limits as of the intersection between the slice and the unit cube boundaries. + + Parameters + ---------- + tleft: float + Intersection of the unit cube with the slice in the negative direction + tright: float + Intersection of the unit cube with the slice in the positive direction + + Returns + ------- + tnew: tuple + Positive and negative slice limits, `(tleft_new, tright_new) = tnew` + """ + tleft_new, tright_new = tleft.copy(), tright.copy() + + return tleft_new, tright_new + + +def slice_limit_to_scale(tleft, tright): + """Return -1..+1 or the intersection between slice and unit cube if that is shorter. + + Parameters + ---------- + tleft: float + Intersection of the unit cube with the slice in the negative direction + tright: float + Intersection of the unit cube with the slice in the positive direction + + Returns + ------- + tnew: tuple + Positive and negative slice limits, `(tleft_new, tright_new) = tnew` + """ + tleft_new = np.fmax(tleft, -1. + np.zeros_like(tleft)) + tright_new = np.fmin(tright, 1. + np.zeros_like(tright)) + + return tleft_new, tright_new + + +class PopulationSimpleSliceSampler(GenericPopulationSampler): + """Vectorized Slice sampler without stepping out procedure for quick look fits. + + Unlike `:py:class:PopulationSliceSampler`, in `:py:class:PopulationSimpleSliceSampler`, + the likelihood is always called with the same number of points. + + Sliced are defined by the `:py:func:generate_direction` function on a interval defined + around the current point. The centred interval has the width of the scale parameter, + i.e, there is no stepping out procedure as in `:py:class:PopulationSliceSampler`. + Slices are then shrink towards the current point until a point is found with a + likelihood above the threshold. + + In the default case, i.e. `scale=None`, the slice width is defined as the + intersection between itself and the unit cube. To improve the efficiency of the sampler, + the slice can be reduced to an interval of size `2*scale` centred on the point. `scale` + can be adapted with the `scale_adapt_factor` parameter based on the median distance + between the current and the next point in a chains among all the chains. If the median + distance is above `scale/adapt_slice_scale_target`, the scale is increased by `scale_adapt_factor`, + and decreased otherwise. The `scale` parameter can also be jittered by a user supplied + function `:py:func:scale_jitter_func` to counter balance the effect of a strong adaptation. + + In the case `scale!=None`, the detailed balance is not guaranteed, so this sampler should + be use with caution. + + Multiple (`popsize`) slice sampling chains are run independently and in parallel. + In that case, we read points as if they were the next selected each after the other. + For a points to update the slice, it needs to be still in the part of the slices + searched after the first point have been read. In that case, we update as normal, + otherwise we discard the point. + """ + + def __init__( + self, popsize, nsteps, generate_direction, + scale_adapt_factor=1.0, adapt_slice_scale_target=2.0, + scale=1.0, scale_jitter_func=None, slice_limit=slice_limit_to_unitcube, + max_it=100, shrink_factor=1.0 + ): + """Initialise. + + Parameters + ---------- + popsize: int + number of walkers to maintain. + nsteps: int + number of steps to take until the found point is accepted as independent. + To calibrate, try several runs with increasing nsteps (doubling). + The ln(Z) should become stable at some value. + generate_direction: function + Function that gives proposal kernel shape, one of: + :py:func:`ultranest.popstepsampler.generate_random_direction` + :py:func:`ultranest.popstepsampler.generate_region_oriented_direction` + :py:func:`ultranest.popstepsampler.generate_region_random_direction` + :py:func:`ultranest.popstepsampler.generate_differential_direction` + :py:func:`ultranest.popstepsampler.generate_mixture_random_direction` + :py:func:`ultranest.popstepsampler.generate_cube_oriented_direction` -> no adaptation in that case + :py:func:`ultranest.popstepsampler.generate_cube_oriented_direction_scaled` -> no adaptation in that case + scale: float + initial guess for the slice width. + scale_jitter_func: function + User supplied function to multiply the `scale` by a random factor. For example, + :py:func:`lambda : scipy.stats.truncnorm.rvs(-0.5, 5., loc=0, scale=1)+1.` + scale_adapt_factor: float + adaptation of `scale`. If 1: no adaptation. if <1, the scale is increased/decreased by this factor if the + final slice length is shorter/longer than the `adapt_slice_scale_target*scale`. + adapt_slice_scale_target: float + Targeted ratio of the median distance between slice mid and final point among all chains of `scale`. + Default: 2.0. Higher values are more conservative, lower values are faster. + slice_limit: function + Function setting the initial slice upper and lower bound. The default is `:py:func:slice_limit_to_unitcube` + which defines the slice limit as the intersection between the slice and the unit cube. An alternative + when the `scale` is used is `:py:func:slice_limit_to_scale` which defines the slice limit as an interval + of size `2*scale`. This function should either return a copy of the `tleft` and `tright` arguments or + new arrays of the same shape. + max_it: int + maximum number of iterations to find a point on the slice. If the maximum number of iterations is reached, + the current point is returned as the next one. + shrink_factor: float + For standard slice sampling shrinking, `shrink_factor=1`, the slice bound is updated to the last + rejected point. Setting `shrink_factor>1` aggressively accelerates the shrinkage, by updating the + new slice bound to `1/shrink_factor` of the distance between the current point and rejected point. + """ + self.nsteps = nsteps + + self.max_it = max_it + self.nrejects = 0 + self.generate_direction = generate_direction + self.scale_adapt_factor = scale_adapt_factor + self.ncalls = 0 + self.discarded = 0 + self.shrink_factor = shrink_factor + assert shrink_factor >= 1.0, "The shrink factor should be greater than 1.0 to be efficient" + + self.scale = float(scale) + + self.adapt_slice_scale_target = adapt_slice_scale_target + + if scale_jitter_func is None: + self.scale_jitter_func = lambda: 1. + else: + self.scale_jitter_func = scale_jitter_func + self.prepared_samples = [] + self.popsize = popsize + + self.slice_limit = slice_limit + + self.logstat = [] + self.logstat_labels = ['accept_rate', 'efficiency', 'scale', 'far_enough', 'mean_rel_jump'] + + def __str__(self): + """Return string representation.""" + return 'PopulationSimpleSliceSampler(popsize=%d, nsteps=%d, generate_direction=%s, scale=%.g)' % ( + self.popsize, self.nsteps, self.generate_direction, self.scale) + + def region_changed(self, Ls, region): + """Act upon region changed. Currently unused.""" + pass + + def __next__( + self, region, Lmin, us, Ls, transform, loglike, ndraw=10, + plot=False, tregion=None, log=False, test=False + ): + """Sample a new live point. + + Parameters + ---------- + region: MLFriends object + Region + Lmin: float + current log-likelihood threshold + us: np.array((nlive, ndim)) + live points + Ls: np.array(nlive) + loglikelihoods live points + transform: function + prior transform function + loglike: function + loglikelihood function + ndraw: int + not used + plot: bool + not used + tregion: bool + not used + log: bool + not used + test: bool + In case of test of the reversibility of the sampler, the points drawn + from the live points needs to be deterministic. This parameters is + ensuring that. + + Returns + ------- + u: np.array(ndim) or None + new point coordinates (None if not yet available) + p: np.array(nparams) or None + new point transformed coordinates (None if not yet available) + L: float or None + new point likelihood (None if not yet available) + nc: int + + """ + nlive, ndim = us.shape + + # fill if empty: + if len(self.prepared_samples) == 0: + # choose live points + ilive = np.random.randint(0, nlive, size=self.popsize) + allu = np.array(us[ilive,:]) if not test else np.array(us) + allp = np.zeros((self.popsize, ndim)) * np.nan + allL = np.array(Ls[ilive]) + nc = 0 + n_discarded = 0 + + interval_final = 0. + + for _k in range(self.nsteps): + # Defining scale jitter + factor_scale = self.scale_jitter_func() + # Defining slice direction + v = self.generate_direction(allu, region, scale=1.0) * self.scale * factor_scale + + # limite of the slice based on the unit cube boundaries + tleft_unitcube, tright_unitcube = unitcube_line_intersection(allu, v) + + # Defining bound of the slice + # Bounds for each points and likelihood calls are identical initially + + # Slice bounds for each likelihood call + tleft_worker, tright_worker = self.slice_limit(tleft_unitcube,tright_unitcube) + + # Slice bounds for each points + tleft, tright = self.slice_limit(tleft_unitcube,tright_unitcube) + # Index of the workers working concurrently + worker_running = np.arange(self.popsize, dtype=int_dtype) + # Status indicating if a points has already find its next position + status = np.zeros(self.popsize, dtype=int_dtype) # one for success, zero for running + + # Loop until each points has found its next position or we reached 100 iterations + for _it in range(self.max_it): + # Sampling points on the slices + slice_position = np.random.uniform(size=(self.popsize,)) + t = tleft_worker + (tright_worker - tleft_worker) * slice_position + + points = allu[worker_running, :] + v_worker = v[worker_running, :] + proposed_u = points + t.reshape((-1,1)) * v_worker + + proposed_p = transform(proposed_u) + proposed_L = loglike(proposed_p) + nc += self.popsize + + # Updating the pool of points based on the newly sampled points + tleft, tright, worker_running, status, allu, allL, allp, n_discarded_it = update_vectorised_slice_sampler( + t, tleft, tright, proposed_L, proposed_u, proposed_p, worker_running, status, Lmin, self.shrink_factor, + allu, allL, allp, self.popsize) + n_discarded += n_discarded_it + + # Update of the limits of the slices + tleft_worker = tleft[worker_running] + tright_worker = tright[worker_running] + + if not np.any(status == 0): + break + + # Record of the final interval on theta for scale adaptation + interval_final += np.median(tright - tleft) + + interval_final = interval_final / self.nsteps + self.discarded += n_discarded + self.ncalls += nc + + assert np.isfinite(allp).all(), 'some walkers never moved! Double nsteps of PopulationSimpleSliceSampler.' + far_enough, (move_distance, reference_distance) = diagnose_move_distances(region, us[ilive,:], allu) + self.prepared_samples = list(zip(allu, allp, allL)) + + self.logstat.append([ + self.popsize / nc, + self.scale, # will always be 1. in the default case + self.nsteps, + np.mean(far_enough) if len(far_enough) > 0 else 0, + np.exp(np.mean(np.log(move_distance / reference_distance + 1e-10))) if len(far_enough) > 0 else 0 + ]) + + # Scale adaptation such that the final interval is + # half the scale. There may be better things to do + # here, but it seems to work. + if interval_final >= 1. / self.adapt_slice_scale_target: + self.scale *= 1. / self.scale_adapt_factor + else: + self.scale *= self.scale_adapt_factor + # print("percentage of throws %.3f\n\n"%((self.throwed/self.ncalls)*100.)) + + else: + nc = 0 + + u, p, L = self.prepared_samples.pop(0) + return u, p, L, nc + + +__all__ = [ + "generate_cube_oriented_direction", "generate_cube_oriented_direction_scaled", + "generate_random_direction", "generate_region_oriented_direction", "generate_region_random_direction", + "PopulationRandomWalkSampler", "PopulationSliceSampler","PopulationSimpleSliceSampler"] diff --git a/ultranest/samplingpath.py b/ultranest/samplingpath.py index 0a800a44..5f61d046 100644 --- a/ultranest/samplingpath.py +++ b/ultranest/samplingpath.py @@ -4,9 +4,9 @@ """ +import matplotlib.pyplot as plt import numpy as np from numpy.linalg import norm -import matplotlib.pyplot as plt def nearest_box_intersection_line(ray_origin, ray_direction, fwd=True): @@ -73,6 +73,13 @@ def nearest_box_intersection_line(ray_origin, ray_direction, fwd=True): def box_line_intersection(ray_origin, ray_direction): """Find intersections of a line with the unit cube, in both sides. + Parameters + ----------- + ray_origin: vector + starting point of line + ray_direction: vector + line direction vector + Returns -------- left: nearest_box_intersection_line return value @@ -83,8 +90,7 @@ def box_line_intersection(ray_origin, ray_direction): """ pF, tF, iF = nearest_box_intersection_line(ray_origin, ray_direction, fwd=True) pN, tN, iN = nearest_box_intersection_line(ray_origin, ray_direction, fwd=False) - if tN > tF or tF < 0: - assert False, "no intersection" + assert not (tN > tF or tF < 0), "no intersection" return (pN, tN, iN), (pF, tF, iF) @@ -156,6 +162,13 @@ def get_sphere_tangent(sphere_center, edge_point): so that edge_point is on the surface. At edge_point, in which direction does the normal vector point? + Parameters + ----------- + sphere_center: vector + center of sphere + edge_point: vector + point at the surface + Returns -------- tangent: vector @@ -175,10 +188,17 @@ def get_sphere_tangents(sphere_center, edge_point): This function is vectorized and handles arrays of arguments. + Parameters + ----------- + sphere_center: array + centers of spheres + edge_point: array + points at the surface + Returns -------- - tangent: vector - vector pointing to the sphere center. + tangent: array + vectors pointing to the sphere center. """ arrow = sphere_center - edge_point @@ -190,14 +210,14 @@ def reflect(v, normal): return v - 2 * (normal * v).sum() * normal -def distances(l, o, r=1): +def distances(direction, center, r=1): """Compute sphere-line intersection. Parameters ----------- - l: vector + direction: vector direction vector (line starts at 0) - o: vector + center: vector center of sphere (coordinate vector) r: float radius of sphere @@ -209,8 +229,8 @@ def distances(l, o, r=1): If no intersection, throws AssertError. """ - loc = (l * o).sum() - osqrnorm = (o**2).sum() + loc = (direction * center).sum() + osqrnorm = (center**2).sum() # print(loc.shape, loc.shape, osqrnorm.shape) rootterm = loc**2 - osqrnorm + r**2 # make sure we are crossing the sphere @@ -344,7 +364,7 @@ def interpolate(i, points, fwd_possible, rwd_possible, contourpath=None): if j == i: # we have this exact point in the chain return xj, vj, Lj, True - assert not k == i # otherwise the above would be true too + assert k != i # otherwise the above would be true too # expand_to_step explores each reflection in detail, so # any points with change in v should have j == i @@ -368,7 +388,7 @@ def interpolate(i, points, fwd_possible, rwd_possible, contourpath=None): return xl, vj, None, True -class SamplingPath(object): +class SamplingPath: """Path described by a (potentially sparse) sequence of points. Convention of the stored point tuple ``(i, x, v, L)``: @@ -445,7 +465,7 @@ def extrapolate(self, i): return newpoint -class ContourSamplingPath(object): +class ContourSamplingPath: """Region-aware form of the sampling path. Uses region points to guess a likelihood contour gradient. diff --git a/ultranest/solvecompat.py b/ultranest/solvecompat.py index 9d799087..274defcf 100644 --- a/ultranest/solvecompat.py +++ b/ultranest/solvecompat.py @@ -1,3 +1,4 @@ +# noqa: D400 D205 """Drop-in replacement for pymultinest.solve. Example:: @@ -11,11 +12,12 @@ """ -import numpy as np import string +import numpy as np + from .integrator import ReactiveNestedSampler -from .stepsampler import RegionBallSliceSampler +from .stepsampler import SliceSampler, generate_mixture_random_direction def pymultinest_solve_compat( @@ -31,6 +33,14 @@ def pymultinest_solve_compat( Disadvantages compared to using ReactiveNestedSampler directly: cannot resume easily, cannot plot interactively. Limited results. + + It is recommended that you directly use:: + + sampler = ReactiveNestedSampler(paramnames, LogLikelihood, transform=Prior) + sampler.run() + + following the UltraNest documentation and manuals, + as this gives you more control on resuming and sampler options. """ if paramnames is None: paramnames = list(string.ascii_lowercase)[:n_dims] @@ -60,12 +70,18 @@ def pymultinest_solve_compat( min_ess=min_ess, frac_remain=frac_remain, Lepsilon=Lepsilon, max_ncalls=40000) - sampler.stepsampler = RegionBallSliceSampler( - nsteps=1000, adaptive_nsteps='move-distance', - region_filter=kwargs.get('region_filter', True)) + sampler.stepsampler = SliceSampler( + nsteps=1000, + generate_direction=generate_mixture_random_direction, + adaptive_nsteps='move-distance', + region_filter=kwargs.get('region_filter', True) + ) else: - sampler.stepsampler = RegionBallSliceSampler( - nsteps=speed, adaptive_nsteps=False, region_filter=False) + sampler.stepsampler = SliceSampler( + generate_direction=generate_mixture_random_direction, + nsteps=speed, + adaptive_nsteps=False, + region_filter=False) sampler.run(dlogz=evidence_tolerance, max_iters=max_iter if max_iter > 0 else None, diff --git a/ultranest/stepfuncs.pyx b/ultranest/stepfuncs.pyx new file mode 100644 index 00000000..c57fc932 --- /dev/null +++ b/ultranest/stepfuncs.pyx @@ -0,0 +1,630 @@ +# cython: language_level=3,annotate=True,profile=True,fast_fail=True,warning_errors=True +""" +Efficient helper functions for vectorized step-samplers +------------------------------------------------------- + +""" + +import numpy as np +cimport numpy as np +np.import_array() +from numpy import nan as np_nan +cimport cython +from cython.parallel import prange + + +ctypedef np.int64_t decl_int_t +int_dtype = np.int64 + + +@cython.boundscheck(False) +@cython.wraparound(False) +cdef _within_unit_cube( + np.float_t [:, :] u, + np.uint8_t [:] acceptable, +): + cdef size_t popsize = u.shape[0] + cdef size_t ndim = u.shape[1] + cdef size_t i, j + + for i in range(popsize): + for j in range(ndim): + if not 0.0 < u[i,j] < 1.0: + acceptable[i] = 0 + break + + +def within_unit_cube(u): + """whether all fields are between 0 and 1, for each row + + Parameters + ---------- + u: np.array((npoints, ndim), dtype=float): + points + + Returns + --------- + within: np.array(npoints, dtype=bool): + for each point, whether it is within the unit cube + """ + acceptable = np.ones(u.shape[0], dtype=bool) + _within_unit_cube(u, acceptable) + return acceptable + + +@cython.boundscheck(False) +@cython.wraparound(False) +cdef _evolve_prepare( + np.ndarray[np.uint8_t, ndim=1] searching_left, + np.ndarray[np.uint8_t, ndim=1] searching_right, + np.ndarray[np.uint8_t, ndim=1] search_right, + np.ndarray[np.uint8_t, ndim=1] bisecting +): + # define three mutually exclusive states: + # stepping out to the left, to the right, bisecting on the slice + cdef size_t n = searching_left.shape[0] + cdef size_t i + for i in range(n): + search_right[i] = not searching_left[i] and searching_right[i] + bisecting[i] = not (searching_left[i] or searching_right[i]) + + +def evolve_prepare(searching_left, searching_right): + """Get auxiliary slice sampler state selectors. + + Vectorized computation for multiple (`nwalkers`) walkers. + + Parameters + ---------- + searching_left: np.array(nwalkers, dtype=bool) + whether stepping out in the negative direction + searching_right: np.array(nwalkers, dtype=bool) + whether stepping out in the positive direction + + Returns + ------- + search_right: np.array(nwalkers, dtype=bool): + if searching right and not left + bisecting: np.array(nwalkers, dtype=bool): + if not searching right nor left any more + """ + search_right = np.empty_like(searching_left) + bisecting = np.empty_like(searching_left) + _evolve_prepare(searching_left, searching_right, search_right, bisecting) + return search_right, bisecting + + +@cython.boundscheck(False) +@cython.wraparound(False) +cpdef evolve_update( + np.ndarray[np.uint8_t, ndim=1] acceptable, + np.ndarray[np.float_t, ndim=1] Lnew, + np.float_t Lmin, + np.ndarray[np.uint8_t, ndim=1] search_right, + np.ndarray[np.uint8_t, ndim=1] bisecting, + np.float_t[:] currentt, + np.float_t[:] current_left, + np.float_t[:] current_right, + np.uint8_t[:] searching_left, + np.uint8_t[:] searching_right, + np.uint8_t[:] success +): + """Update the state of each walker. + + This uses the robust logic of slice sampling, + with stepping out by doubling. + + Parameters + ---------- + acceptable: np.array(nwalkers, dtype=bool) + whether a likelihood evaluation was made. If false, rejected because out of contour. + Lnew: np.array(acceptable.sum(), dtype=bool) + likelihood value of proposed point + Lmin: float + current log-likelihood threshold + search_right: np.array(nwalkers, dtype=bool) + whether stepping out in the positive direction + bisecting: np.array(nwalkers, dtype=bool) + whether bisecting. If neither search_right nor bisecting, then + currentt: np.array(nwalkers) + proposed coordinate on the slice + current_left: np.array(nwalkers) + current slice negative end + current_right: np.array(nwalkers) + current slice positive end + searching_left: np.array(nwalkers, dtype=bool) + whether stepping out in the negative direction + searching_right: np.array(nwalkers, dtype=bool) + whether stepping out in the positive direction + success: np.array(nwalkers, dtype=bool) + whether the walker accepts the point. + + Notes + ----- + Writes to `currentt`, `current_left`, `current_right`, + `searching_left`, `searching_right`, `success`. + """ + cdef size_t popsize = acceptable.shape[0] + cdef size_t j = 0 + cdef size_t i + cdef float my_nan = np_nan + + for k in range(popsize): + if acceptable[k]: + if Lnew[j] > Lmin: + success[k] = 1 + j += 1 + + for i in prange(popsize, nogil=True): + # handle cases based on the result: + # 1) step out further, if still accepting + if success[i] != 0: + if searching_left[i]: + current_left[i] *= 2 + elif search_right[i]: + current_right[i] *= 2 + # 2) done stepping out, if rejected + else: + if searching_left[i]: + searching_left[i] = 0 + elif search_right[i]: + searching_right[i] = 0 + # bisecting, rejected or not acceptable + if bisecting[i]: + if currentt[i] < 0: + # bisect shrink left: + current_left[i] = currentt[i] + else: + current_right[i] = currentt[i] + # bisect accepted: start new slice and new generation there + if success[i] != 0: + currentt[i] = my_nan + else: + success[i] = 0 + +# precompute to avoid slow allocations. +pnew_empty = np.empty((0,1)) +Lnew_empty = np.empty(0) + +def evolve( + transform, loglike, Lmin, + currentu, currentL, currentt, currentv, + current_left, current_right, searching_left, searching_right +): + """Evolve each slice sampling walker. + + Parameters + ---------- + transform: function + prior transform function + loglike: function + loglikelihood function + Lmin: float + current log-likelihood threshold + currentu: np.array((nwalkers, ndim)) + slice starting point (where currentt=0) + currentL: np.array(nwalkers) + current loglikelihood + currentt: np.array(nwalkers) + proposed coordinate on the slice + currentv: np.array((nwalkers, ndim)) + slice direction vector + current_left: np.array(nwalkers) + current slice negative end + current_right: np.array(nwalkers) + current slice positive end + searching_left: np.array(nwalkers, dtype=bool) + whether stepping out in the negative direction + searching_right: np.array(nwalkers, dtype=bool) + whether stepping out in the positive direction + + Returns + ------- + currentt: np.array(nwalkers) + as above + currentv: np.array((nwalkers, ndim)) + as above + current_left: np.array(nwalkers) + as above + current_right: np.array(nwalkers) + as above + searching_left: np.array(nwalkers, dtype=bool) + as above + searching_right: np.array(nwalkers, dtype=bool) + as above + success: np.array(nwalkers, dtype=bool) + whether the walker accepts the point. + unew: np.array((success.sum(), ndim)) + coordinates of accepted points + pnew: np.array((success.sum(), nparams)) + transformed coordinates of accepted points + Lnew: np.array(success.sum()) + log-likelihoods of accepted points + nc: int + number of points for which the log-likelihood function was called. + + This function writes in-place to + `currentt`, `currentv`, `current_left`, `current_right`, `searching_left`, + `searching_right` and `currentu`, but also returns these. + """ + search_right, bisecting = evolve_prepare(searching_left, searching_right) + + unew = currentu + unew[searching_left,:] = currentu[searching_left,:] + currentv[searching_left,:] * current_left[searching_left].reshape((-1,1)) + unew[search_right,:] = currentu[search_right,:] + currentv[search_right,:] * current_right[search_right].reshape((-1,1)) + currentt[bisecting] = np.random.uniform(current_left[bisecting], current_right[bisecting]) + unew[bisecting,:] = currentu[bisecting,:] + currentv[bisecting,:] * currentt[bisecting].reshape((-1,1)) + + acceptable = within_unit_cube(unew) + + nc = 0 + if acceptable.any(): + pnew = transform(unew[acceptable,:]) + Lnew = loglike(pnew) + nc += len(pnew) + else: + pnew = pnew_empty + Lnew = Lnew_empty + + success = np.zeros_like(searching_left) + evolve_update( + acceptable, Lnew, Lmin, search_right, bisecting, currentt, + current_left, current_right, searching_left, searching_right, + success + ) + + return ( + ( + currentt, currentv, + current_left, current_right, searching_left, searching_right), + (success, unew[success,:], pnew[success[acceptable],:], Lnew[success[acceptable]]), + nc + ) + + +def step_back(Lmin, allL, generation, currentt, log=False): + """Revert walkers which have wandered astray. + + Revert until all previous steps have likelihoods allL above Lmin. + Updates currentt, generation and allL, in-place. + + Parameters + ---------- + Lmin: float + current loglikelihood threshold + allL: np.array((nwalkers, ngenerations)) + loglikelihoods of the chain. NaN where not evaluated yet. + generation: np.array(nwalkers, dtype=int) + how many iterations each walker has completed. + currentt: np.array(nwalkers) + current slice coordinate + log: bool + whether to print when steps are reverted + + + """ + # step back where step was excluded by Lmin increase + # delete from the back until all are good: + max_width = generation.max() + 1 + below_threshold = allL[:,:max_width] < Lmin + problematic_parent = np.any(below_threshold, axis=1) + if not problematic_parent.any(): + return + parent_i, = np.where(problematic_parent) + below_threshold_parent = below_threshold[parent_i,:] + # first, all of them (because we already identified them) + problematic = np.ones(len(parent_i), dtype=bool) + step = 0 + + while True: + step += 1 + ii, = np.where(problematic) + i = parent_i[problematic] + g = generation[i] + generation[i] -= 1 + currentt[i] = np_nan + allL[i,g] = np_nan + below_threshold_parent[problematic, g] = False + if log: + print("resetting %d%%" % (problematic.meancount_good_generations() * 100), 'by', step, 'steps', 'to', g) + + del problematic + problematic = np.any(below_threshold_parent, axis=1) + if not problematic.any(): + break + + +cdef _fill_directions( + np.ndarray[np.float_t, ndim=2] v, + np.ndarray[decl_int_t, ndim=1] indices, + float scale +): + cdef size_t nsamples = v.shape[0] + cdef size_t i + for i in range(nsamples): + v[i, indices[i]] = scale + + +def generate_cube_oriented_direction(ui, region, scale=1): + """Draw a unit direction vector in direction of a random unit cube axes. + + Parameters + ---------- + ui: np.array((npoints, ndim), dtype=float) + starting points (not used) + region: + not used + scale: float + length of returned vector + + Returns + --------- + v: np.array((npoints, ndim), dtype=float) + Random axis vectors of length `scale`, one for each starting point. + """ + nsamples, ndim = ui.shape + v = np.zeros((nsamples, ndim)) + # choose axis + j = np.random.randint(ndim, size=nsamples, dtype=int_dtype) + _fill_directions(v, j, scale) + return v + + +def generate_cube_oriented_direction_scaled(ui, region, scale=1): + """Draw a unit direction vector in direction of a random unit cube axes. + Scale by the live point min-max range. + + Parameters + ---------- + ui: np.array((npoints, ndim), dtype=float) + starting points (not used) + region: + not used + scale: float + length of returned vector + + Returns + --------- + v: np.array((npoints, ndim), dtype=float) + Random axis vectors of length `scale`, one for each starting point. + """ + nsamples, ndim = ui.shape + v = np.zeros((nsamples, ndim)) + scales = region.u.std(axis=0) + # choose axis + j = np.random.randint(ndim, size=nsamples, dtype=int_dtype) + _fill_directions(v, j, scale) + v *= scales[j].reshape((-1, 1)) + return v + +def generate_random_direction(ui, region, scale=1): + """Draw uniform direction vector in unit cube space of length `scale`. + + Parameters + ----------- + ui: np.array((npoints, ndim), dtype=float) + starting points (not used) + region: MLFriends object + current region (not used) + scale: float + length of direction vector + + Returns + -------- + v: array + new direction vector + """ + del region + nsamples, ndim = ui.shape + v = np.random.normal(size=(nsamples, ndim)) + v *= scale / np.linalg.norm(v, axis=1).reshape((nsamples, 1)) + return v + + +def generate_region_oriented_direction(ui, region, scale=1): + """Draw a random direction vector in direction of one of the `region` axes. + + If given, the vector length is `scale`. + If not, the vector length in transformed space is `tscale`. + + Parameters + ----------- + ui: np.array((npoints, ndim), dtype=float) + starting points (not used) + region: MLFriends object + current region + scale: float + length of direction vector in t-space + + Returns + -------- + v: array + new direction vector (in u-space) + """ + nsamples, ndim = ui.shape + # choose axis in transformed space: + j = np.random.randint(ndim, size=nsamples, dtype=int_dtype) + v = region.transformLayer.axes[j] * scale + return v + + +def generate_region_random_direction(ui, region, scale=1): + """Draw a direction vector in a random direction of the region. + + The vector length is `scale` (in unit cube space). + + Parameters + ----------- + ui: np.array((npoints, ndim), dtype=float) + starting points (not used) + region: MLFriends object + current region + scale: float: + length of direction vector (in t-space) + + Returns + -------- + v: array + new direction vector + """ + nsamples, ndim = ui.shape + # choose axis in transformed space: + v1 = np.random.normal(size=(nsamples, ndim)) + v1 *= scale / np.linalg.norm(v1, axis=1).reshape((nsamples, 1)) + v = np.einsum('ij,kj->ki', region.transformLayer.axes, v1) + return v + +def generate_differential_direction(ui, region, scale=1): + """Sample a vector using the difference between two randomly selected live points. + + Parameters + ----------- + ui: np.array((npoints, ndim), dtype=float) + starting point + region: MLFriends object + current region + scale: float: + length of direction vector (in t-space) + + Returns + -------- + v: array + new direction vector + """ + nsamples, ndim = ui.shape + nlive, ndim = region.u.shape + # choose pair + i = np.random.randint(nlive, size=nsamples, dtype=int_dtype) + i2 = np.random.randint(nlive - 1, size=nsamples, dtype=int_dtype) + i2[i2 >= i] += 1 + + # compute difference vector + v = (region.u[i,:] - region.u[i2,:]) * scale + return v + + + +def generate_mixture_random_direction(ui, region, scale=1): + """Sample randomly uniformly from two proposals. + + Randomly applies either :py:func:`generate_differential_direction`, + which transports far, or :py:func:`generate_region_oriented_direction`, + which is stiffer. + + Best method according to https://arxiv.org/abs/2211.09426 + + Parameters + ----------- + ui: np.array((npoints, ndim), dtype=float) + starting point + region: MLFriends object + current region + scale: float: + length of direction vector (in t-space) + + Returns + -------- + v: array + new direction vector + """ + nsamples, ndim = ui.shape + v_DE = generate_differential_direction(ui, region, scale=scale) + v_axis = generate_region_oriented_direction(ui, region, scale=scale) + return np.where(np.random.uniform(size=nsamples).reshape((-1, 1)) < 0.5, v_DE, v_axis) + +@cython.boundscheck(False) +@cython.wraparound(False) +cpdef tuple update_vectorised_slice_sampler( + np.ndarray[np.float_t, ndim=1] t, + np.ndarray[np.float_t, ndim=1] tleft, + np.ndarray[np.float_t, ndim=1] tright, + np.ndarray[np.float_t, ndim=1] proposed_L, + np.ndarray[np.float_t, ndim=2] proposed_u, + np.ndarray[np.float_t, ndim=2] proposed_p, + np.ndarray[decl_int_t, ndim=1] worker_running, + np.ndarray[decl_int_t, ndim=1] status, + np.float_t Likelihood_threshold, + np.float_t shrink_factor, + np.ndarray[np.float_t, ndim=2] allu, + np.ndarray[np.float_t, ndim=1] allL, + np.ndarray[np.float_t, ndim=2] allp, + int popsize +): + """Update the slice sampler state of each walker in the populations. + + Parameters + ----------- + t: array + proposed slice coordinate + tleft: array + current slice negative end + tright: array + current slice positive end + proposed_L: array + log-likelihood of proposed point + proposed_u: array + proposed point in unit cube space + proposed_p: array + proposed point in transformed space + worker_running: array + index of the point associated with each worker + status: array + integer status of the point + Likelihood_threshold: float + current log-likelihood threshold + shrink_factor: float + factor by which to shrink the slice + allu: array + Accepted points in unit cube space + allL: array + log-likelihoods of accepted points + allp: array + Accepted points in transformed space + popsize: int + number of points + + Returns + -------- + tleft: array + updated current slice negative end + tright: array + updated current slice positive end + worker_running: array + updated index of the point associated with each worker + status: array + updated integer status of the point + allu: array + updated accepted points in unit cube space + allL: array + updated log-likelihoods of accepted points + allp: array + updated accepted points in transformed space + discarded: int + Point where the likelihood was evaluated but was not taken into account. + """ + + cdef int j, k + cdef discarded = 0 + for l in range(popsize): + if t[l] > tright[worker_running[l]] or t[l] < tleft[worker_running[l]]: + if proposed_L[l]>Likelihood_threshold: + discarded+=1 + continue + if 0 < t[l] < tright[worker_running[l]]: + tright[worker_running[l]] = t[l]/shrink_factor + if 0 > t[l] > tleft[worker_running[l]]: + tleft[worker_running[l]] = t[l]/shrink_factor + if proposed_L[l] > Likelihood_threshold and status[worker_running[l]] == 0: + status[worker_running[l]] = 1 + allu[worker_running[l], :] = proposed_u[l, :] + allL[worker_running[l]] = proposed_L[l] + allp[worker_running[l], :] = proposed_p[l, :] + + j = 0 + while j < popsize and (status == 0).any(): + for k in range(popsize): + if status[k] == 0 and j < popsize: + worker_running[j] = k + j += 1 + + return (tleft, tright, worker_running, status, allu, allL, allp,discarded) diff --git a/ultranest/stepsampler.py b/ultranest/stepsampler.py index 9d635a77..3b2c2b57 100644 --- a/ultranest/stepsampler.py +++ b/ultranest/stepsampler.py @@ -1,27 +1,38 @@ -"""MCMC-like step sampling within a region. +# noqa: D400 D205 +""" +MCMC-like step sampling +----------------------- The classes implemented here are generators that, in each iteration, -only make one likelihood call. This allows keeping a population of -samplers that have the same execution time per call, even if they -do not terminate at the same number of iterations. +only make one likelihood call. This allows running in parallel a +population of samplers that have the same execution time per call, +even if they do not terminate at the same number of iterations. """ -from __future__ import print_function, division -import numpy as np +from __future__ import division, print_function + +from warnings import warn + import matplotlib.pyplot as plt +import numpy as np + from .utils import listify as _listify def generate_random_direction(ui, region, scale=1): - """Draw uniform direction vector in unit cube space of length `scale`. + """Sample uniform direction vector in unit cube space of length `scale`. + + Samples a direction from a unit multi-variate Gaussian. Parameters ----------- + ui: array + starting point region: MLFriends object current region (not used) scale: float length of direction vector - + Returns -------- v: array @@ -34,12 +45,18 @@ def generate_random_direction(ui, region, scale=1): def generate_cube_oriented_direction(ui, region, scale=1): - """Draw a unit direction vector in direction of a random unit cube axes. + """Sample a unit direction vector in direction of a random unit cube axes. + + Chooses one parameter, randomly uniformly, upon which the slice will be defined. Parameters ----------- + ui: array + starting point region: MLFriends object current region (not used) + scale: float + factor to multiple the vector Returns -------- @@ -56,18 +73,129 @@ def generate_cube_oriented_direction(ui, region, scale=1): return v +def generate_cube_oriented_differential_direction(ui, region, scale=1): + """Sample a direction vector on a randomly chose parameter based on two randomly selected live points. + + Chooses one parameter, randomly uniformly, upon which the slice will be defined. + Guess the length from the difference of two points in that axis. + + Parameters + ----------- + ui: array + starting point + region: MLFriends object + current region + scale: float + factor to multiple the vector + + Returns + -------- + v: array + new direction vector + """ + nlive, ndim = region.u.shape + v = np.zeros(ndim) + + # choose axis + j = np.random.randint(ndim) + # choose pair + while v[j] == 0: + i = np.random.randint(nlive) + i2 = np.random.randint(nlive - 1) + if i2 >= i: + i2 += 1 + + v[j] = (region.u[i,j] - region.u[i2,j]) * scale + + return v + + +def generate_differential_direction(ui, region, scale=1): + """Sample a vector using the difference between two randomly selected live points. + + Parameters + ----------- + ui: array + starting point + region: MLFriends object + current region + scale: float + factor to multiple the vector + + Returns + -------- + v: array + new direction vector + """ + nlive, ndim = region.u.shape + # choose pair + i = np.random.randint(nlive) + i2 = np.random.randint(nlive - 1) + if i2 >= i: + i2 += 1 + + # use doubling procedure to identify left and right maxima borders + v = (region.u[i,:] - region.u[i2,:]) * scale + return v + + +def generate_partial_differential_direction(ui, region, scale=1): + """Sample a vector using the difference between two randomly selected live points. + + Only 10% of parameters are allowed to vary at a time. + + Parameters + ----------- + ui: array + starting point + region: MLFriends object + current region + scale: float + factor to multiple the vector + + Returns + -------- + v: array + new direction vector + """ + nlive, ndim = region.u.shape + # choose pair + i = np.random.randint(nlive) + while True: + i2 = np.random.randint(nlive - 1) + if i2 >= i: + i2 += 1 + + v = region.u[i] - region.u[i2] + + # choose which parameters to be off + mask = np.random.uniform(size=ndim) > 0.1 + # at least one must be free to vary + mask[np.random.randint(ndim)] = False + v[mask] = 0 + if (v != 0).any(): + # repeat if live points are identical + break + # use doubling procedure to identify left and right maxima borders + # v = np.zeros(ndim) + # v[mask] = (region.u[i,mask] - region.u[i2,mask]) * scale + return v + + def generate_region_oriented_direction(ui, region, scale=1): - """Draw a random direction vector in direction of one of the `region` axes. + """Sample a vector along one `region` principle axes, chosen at random. - If given, the vector length is `scale`. - If not, the vector length in transformed space is `tscale`. + The region transformLayer axes are considered (:py:class:`AffineLayer` or :py:class:`ScalingLayer`). + One axis is chosen at random. Parameters ----------- + ui: array + starting point region: MLFriends object current region scale: float - length of direction vector in t-space + factor to multiple the vector Returns -------- @@ -81,17 +209,21 @@ def generate_region_oriented_direction(ui, region, scale=1): def generate_region_random_direction(ui, region, scale=1): - """Draw a direction vector in a random direction of the region. + """Sample a direction vector based on the region covariance. - The vector length is `scale` (in unit cube space). + The region transformLayer axes are considered (:py:class:`AffineLayer` or :py:class:`ScalingLayer`). + With this covariance matrix, a random direction is generated. + Generating proceeds by transforming a unit multi-variate Gaussian. Parameters ----------- + ui: array + starting point region: MLFriends object current region scale: float: length of direction vector (in t-space) - + Returns -------- v: array @@ -99,20 +231,26 @@ def generate_region_random_direction(ui, region, scale=1): """ # choose axis in transformed space: v1 = np.random.normal(0, 1, size=len(ui)) - v1 *= scale / (v1**2).sum()**0.5 + v1 *= scale / np.linalg.norm(v1) v = np.dot(region.transformLayer.axes, v1) return v -def generate_mixture_random_direction(ui, region, scale=1, uniform_weight=1e-6): - """Draw from a mix of a ball proposal and a region-shaped proposal. +def generate_mixture_random_direction(ui, region, scale=1): + """Sample randomly uniformly from two proposals. + + Randomly applies either :py:func:`generate_differential_direction`, + which transports far, or :py:func:`generate_region_oriented_direction`, + which is stiffer. + + Best method according to https://arxiv.org/abs/2211.09426 Parameters ----------- region: MLFriends region - uniform_weight: float - sets the weight for the equal-axis ball contribution + ui: array + vector of starting point scale: float length of the vector. @@ -121,13 +259,38 @@ def generate_mixture_random_direction(ui, region, scale=1, uniform_weight=1e-6): v: array new direction vector """ - v1 = generate_random_direction(ui, region) - v1 /= (v1**2).sum()**0.5 - v2 = generate_region_random_direction(ui, region) - v2 /= (v2**2).sum()**0.5 - v = (v1 * uniform_weight + v2 * (1 - uniform_weight)) - v *= scale * (v2**2).sum()**0.5 - return v + if np.random.uniform() < 0.5: + # DE proposal + return generate_differential_direction(ui, region, scale=scale) + else: + # region-oriented random axis proposal + return generate_region_oriented_direction(ui, region, scale=scale) + + +def generate_region_sample_direction(ui, region, scale=1): + """Sample a point directly from the region, and return the difference vector to the current point. + + Parameters + ----------- + region: MLFriends + region + ui: array + vector of starting point + scale: float + length of the vector. + + Returns + -------- + v: array + new direction vector + """ + while True: + upoints = region.sample(nsamples=200) + if len(upoints) != 0: + break + # we only need the first one + u = upoints[0,:] + return (u - ui) * scale def _inside_region(region, unew, uold): @@ -157,7 +320,7 @@ def inside_region(region, unew, uold): point to check uold: array not used - + Returns -------- v: array @@ -166,9 +329,12 @@ def inside_region(region, unew, uold): del uold return region.inside(unew) + def adapt_proposal_total_distances(region, history, mean_pair_distance, ndim): + """Check jump distance (deprecated function).""" # compute mean vector of each proposed jump # compute total distance of all jumps + warn('adapt_proposal_total_distances is deprecated and will be removed in future versions of UltraNest.', DeprecationWarning, stacklevel=2) tproposed = region.transformLayer.transform(np.asarray([u for u, _ in history])) assert len(tproposed.sum(axis=1)) == len(tproposed) d2 = ((((tproposed[0] - tproposed)**2).sum(axis=1))**0.5).sum() @@ -176,9 +342,12 @@ def adapt_proposal_total_distances(region, history, mean_pair_distance, ndim): return far_enough, [d2, mean_pair_distance] + def adapt_proposal_total_distances_NN(region, history, mean_pair_distance, ndim): + """Check jump distance (deprecated function).""" # compute mean vector of each proposed jump # compute total distance of all jumps + warn('adapt_proposal_total_distances_NN is deprecated and will be removed in future versions of UltraNest.', DeprecationWarning, stacklevel=2) tproposed = region.transformLayer.transform(np.asarray([u for u, _ in history])) assert len(tproposed.sum(axis=1)) == len(tproposed) d2 = ((((tproposed[0] - tproposed)**2).sum(axis=1))**0.5).sum() @@ -186,23 +355,53 @@ def adapt_proposal_total_distances_NN(region, history, mean_pair_distance, ndim) return far_enough, [d2, region.maxradiussq**0.5] + def adapt_proposal_summed_distances(region, history, mean_pair_distance, ndim): + """Check jump distance (deprecated function).""" # compute sum of distances from each jump + warn('adapt_proposal_summed_distances is deprecated and will be removed in future versions of UltraNest.', DeprecationWarning, stacklevel=2) tproposed = region.transformLayer.transform(np.asarray([u for u, _ in history])) d2 = (((tproposed[1:,:] - tproposed[:-1,:])**2).sum(axis=1)**0.5).sum() far_enough = d2 > mean_pair_distance / ndim return far_enough, [d2, mean_pair_distance] + def adapt_proposal_summed_distances_NN(region, history, mean_pair_distance, ndim): + """Check jump distance (deprecated function).""" # compute sum of distances from each jump + warn('adapt_proposal_summed_distances_NN is deprecated and will be removed in future versions of UltraNest.', DeprecationWarning, stacklevel=2) tproposed = region.transformLayer.transform(np.asarray([u for u, _ in history])) d2 = (((tproposed[1:,:] - tproposed[:-1,:])**2).sum(axis=1)**0.5).sum() far_enough = d2 > region.maxradiussq**0.5 return far_enough, [d2, region.maxradiussq**0.5] + def adapt_proposal_move_distances(region, history, mean_pair_distance, ndim): + """Compare random walk travel distance to MLFriends radius. + + Compares in whitened space (t-space), the L2 norm between final + point and starting point to the MLFriends bootstrapped radius. + + Parameters + ---------- + region: MLFriends + built region + history: list + list of tuples, containing visited point and likelihood. + mean_pair_distance: float + not used + ndim: int + dimensionality + + Returns + ------- + far_enough: bool + whether the distance is larger than the radius + info: tuple + distance and radius (both float) + """ # compute distance from start to end ustart, _ = history[0] ufinal, _ = history[-1] @@ -210,9 +409,34 @@ def adapt_proposal_move_distances(region, history, mean_pair_distance, ndim): d2 = ((tstart - tfinal)**2).sum() far_enough = d2 > region.maxradiussq - return far_enough, [d2, region.maxradiussq**0.5] + return far_enough, [d2**0.5, region.maxradiussq**0.5] + def adapt_proposal_move_distances_midway(region, history, mean_pair_distance, ndim): + """Compare first half of the travel distance to MLFriends radius. + + Compares in whitened space (t-space), the L2 norm between the + middle point of the walk and the starting point, + to the MLFriends bootstrapped radius. + + Parameters + ---------- + region: MLFriends + built region + history: list + list of tuples, containing visited point and likelihood. + mean_pair_distance: float + not used + ndim: int + dimensionality + + Returns + ------- + far_enough: bool + whether the distance is larger than the radius + info: tuple + distance and radius (both float) + """ # compute distance from start to end ustart, _ = history[0] middle = max(1, len(history) // 2) @@ -221,9 +445,107 @@ def adapt_proposal_move_distances_midway(region, history, mean_pair_distance, nd d2 = ((tstart - tfinal)**2).sum() far_enough = d2 > region.maxradiussq - return far_enough, [d2, region.maxradiussq**0.5] + return far_enough, [d2**0.5, region.maxradiussq**0.5] + + +def select_random_livepoint(us, Ls, Lmin): + """Select random live point as chain starting point. + + Parameters + ---------- + us: array + positions of live points + Ls: array + likelihood of live points + Lmin: float + current log-likelihood threshold + + Returns + ------- + i: int + index of live point selected + """ + return np.random.randint(len(Ls)) + + +class IslandPopulationRandomLivepointSelector: + """Mutually isolated live point subsets. + + To replace dead points, chains are only started from the same + island as the dead point. Island refers to chunks of + live point indices (0,1,2,3 as stored, not sorted). + Each chunk has size ´island_size´. + + If ´island_size´ is large, for example, the total number of live points, + then clumping can occur more easily. This is the observed behaviour + that a limited random walk is run from one live point, giving + two similar points, then the next dead point replacement is + likely run again from these, giving more and more similar live points. + This gives a run-away process leading to clumps of similar, + highly correlated points. + + If ´island_size´ is small, for example, 1, then each dead point + is replaced by a chain started from it. This is a problem because + modes can never die out. Nested sampling can then not complete. + + In a multi-modal run, within a given number of live points, + the number of live points per mode is proportional to the mode's + prior volume, but can fluctuate. If the number of live points + is small, a fluctuation can lead to mode die-out, which cannot + be reversed. Therefore, the number of island members should be + large enough to represent each mode. + """ + + def __init__(self, island_size, exchange_probability=0): + """Set up multiple isolated islands. + + Parameters + ----------- + island_size: int + maximum number of members on each isolated live point + population. + + exchange_probability: float + Probability that a member from a random island will be picked. + + """ + assert island_size > 0 + self.island_size = island_size + assert 0 <= exchange_probability <= 1 + self.exchange_probability = exchange_probability + + def __call__(self, us, Ls, Lmin): + """Select live point as chain starting point. + + Parameters + ---------- + us: array + positions of live points + Ls: array + likelihood of live points + Lmin: float + current log-likelihood threshold + + Returns + ------- + i: int + index of live point selected + """ + mask_deadpoints = Lmin == Ls + if not mask_deadpoints.any() or (self.exchange_probability > 0 and np.random.uniform() < self.exchange_probability): + return np.random.randint(len(Ls)) + + # find the dead point we should replace + j = np.where(mask_deadpoints)[0][0] + # start in the same island + island = j // self.island_size + # pick a random member from the island + return np.random.randint( + island * self.island_size, + min(len(Ls), (island + 1) * self.island_size)) -class StepSampler(object): + +class StepSampler: """Base class for a simple step sampler, staggering around. Scales proposal towards a 50% acceptance rate. @@ -231,8 +553,9 @@ class StepSampler(object): def __init__( self, nsteps, generate_direction, - scale=1.0, adaptive_nsteps=False, max_nsteps=1000, + scale=1.0, check_nsteps='move-distance', adaptive_nsteps=False, max_nsteps=1000, region_filter=False, log=False, + starting_point_selector=select_random_livepoint, ): """Initialise sampler. @@ -244,10 +567,57 @@ def __init__( nsteps: int number of accepted steps until the sample is considered independent. - adaptive_nsteps: False, 'proposal-distance', 'move-distance' - Select a strategy to adapt the number of steps. The strategies - make sure that: + To find the right value, see :py:class:`ultranest.calibrator.ReactiveNestedCalibrator` + + generate_direction: function + direction proposal function. + Available are: + + * :py:func:`generate_cube_oriented_direction` + (slice sampling, picking one random parameter to vary) + * :py:func:`generate_random_direction` + (hit-and-run sampling, picking a random direction varying all parameters) + * :py:func:`generate_differential_direction` + (differential evolution direction proposal) + * :py:func:`generate_region_oriented_direction` + (slice sampling, but in the whitened parameter space) + * :py:func:`generate_region_random_direction` + (hit-and-run sampling, but in the whitened parameter space) + * :py:class:`SequentialDirectionGenerator` + (sequential slice sampling, i.e., iterate deterministically through the parameters) + * :py:class:`SequentialRegionDirectionGenerator` + (sequential slice sampling in the whitened parameter space, i.e., iterate deterministically through the principle axes) + * :py:func:`generate_cube_oriented_differential_direction` + (like generate_differential_direction, but along only one randomly chosen parameter) + * :py:func:`generate_partial_differential_direction` + (differential evolution slice proposal on only 10% of the parameters) + * :py:func:`generate_mixture_random_direction` + (combined proposal) + + Additionally, :py:class:`OrthogonalDirectionGenerator` + can be applied to any generate_direction function. + + When in doubt, try :py:func:`generate_mixture_random_direction`. + It combines efficient moves along the live point distribution, + with robustness against collapse to a subspace. + :py:func:`generate_cube_oriented_direction` works well too. + + adaptive_nsteps: False or str + Strategy to adapt the number of steps. + The possible values are the same as for `check_nsteps`. + + Adapting can give usable results. However, strictly speaking, + detailed balance is not maintained, so the results can be biased. + You can use the stepsampler.logstat property to find out the `nsteps` learned + from one run (third column), and use the largest value for `nsteps` + for a fresh run. + The forth column is the jump distance, the fifth column is the reference distance. + + check_nsteps: False or str + Method to diagnose the step sampler walks. The options are: + + * False: no checking * 'move-distance' (recommended): distance between start point and final position exceeds the mean distance between pairs of live points. @@ -267,6 +637,10 @@ def __init__( between chain points exceeds mean distance between pairs of live points. + Each step sampler walk adds one row to stepsampler.logstat. + The jump distance (forth column) should be compared to + the reference distance (fifth column). + max_nsteps: int Maximum number of steps the adaptive_nsteps can reach. @@ -279,48 +653,69 @@ def __init__( proposal scale, number of steps, jump distance and distance between live points + starting_point_selector: func + function which given the live point positions us, + their log-likelihoods Ls and the current log-likelihood + threshold Lmin, returns the index i of the selected live + point to start a new chain from. + Examples: :py:func:`select_random_livepoint`, which has + always been the default behaviour, + or an instance of :py:class:`IslandPopulationRandomLivepointSelector`. + """ self.history = [] self.nsteps = nsteps self.nrejects = 0 - self.scale = 1.0 + self.scale = scale self.max_nsteps = max_nsteps self.next_scale = self.scale - self.last = None, None self.nudge = 1.1**(1. / self.nsteps) self.nsteps_nudge = 1.01 self.generate_direction = generate_direction - adaptive_nsteps_options = { + check_nsteps_options = { False: None, 'move-distance': adapt_proposal_move_distances, 'move-distance-midway': adapt_proposal_move_distances_midway, - 'proposal-total-distances': adapt_proposal_total_distances, + 'proposal-total-distances': adapt_proposal_total_distances, 'proposal-total-distances-NN': adapt_proposal_total_distances_NN, 'proposal-summed-distances': adapt_proposal_summed_distances, 'proposal-summed-distances-NN': adapt_proposal_summed_distances_NN, } + adaptive_nsteps_options = dict(check_nsteps_options) if adaptive_nsteps not in adaptive_nsteps_options.keys(): raise ValueError("adaptive_nsteps must be one of: %s, not '%s'" % (adaptive_nsteps_options, adaptive_nsteps)) + if check_nsteps not in check_nsteps_options.keys(): + raise ValueError("check_nsteps must be one of: %s, not '%s'" % (adaptive_nsteps_options, adaptive_nsteps)) self.adaptive_nsteps = adaptive_nsteps + if self.adaptive_nsteps: + assert nsteps <= max_nsteps, 'Invalid adapting configuration: provided nsteps=%d exceeds provided max_nsteps=%d' % (nsteps, max_nsteps) self.adaptive_nsteps_function = adaptive_nsteps_options[adaptive_nsteps] + self.check_nsteps = check_nsteps + self.check_nsteps_function = check_nsteps_options[check_nsteps] self.adaptive_nsteps_needs_mean_pair_distance = self.adaptive_nsteps in ( 'proposal-total-distances', 'proposal-summed-distances', + ) or self.check_nsteps in ( + 'proposal-total-distances', 'proposal-summed-distances', ) + self.starting_point_selector = starting_point_selector self.mean_pair_distance = np.nan self.region_filter = region_filter + if log: + assert hasattr(log, 'write'), 'log argument should be a file, use log=open(filename, "w") or similar' self.log = log self.logstat = [] self.logstat_labels = ['rejection_rate', 'scale', 'steps'] - if adaptive_nsteps: + if adaptive_nsteps or check_nsteps: self.logstat_labels += ['jump-distance', 'reference-distance'] def __str__(self): + """Return string representation.""" if not self.adaptive_nsteps: - return type(self).__name__ + '(nsteps=%d)' % self.nsteps + return type(self).__name__ + '(nsteps=%d, generate_direction=%s)' % (self.nsteps, self.generate_direction) else: - return type(self).__name__ + '(adaptive_nsteps=%s)' % self.adaptive_nsteps + return type(self).__name__ + '(adaptive_nsteps=%s, generate_direction=%s)' % (self.adaptive_nsteps, self.generate_direction) def plot(self, filename): """Plot sampler statistics. @@ -353,6 +748,98 @@ def plot(self, filename): header=','.join(self.logstat_labels), delimiter=',') plt.close() + @property + def mean_jump_distance(self): + """Geometric mean jump distance.""" + if len(self.logstat) == 0: + return np.nan + if 'jump-distance' not in self.logstat_labels or 'reference-distance' not in self.logstat_labels: + return np.nan + i = self.logstat_labels.index('jump-distance') + j = self.logstat_labels.index('reference-distance') + jump_distances = np.array([entry[i] for entry in self.logstat]) + reference_distances = np.array([entry[j] for entry in self.logstat]) + return np.exp(np.nanmean(np.log(jump_distances / reference_distances + 1e-10))) + + @property + def far_enough_fraction(self): + """Fraction of jumps exceeding reference distance.""" + if len(self.logstat) == 0: + return np.nan + if 'jump-distance' not in self.logstat_labels or 'reference-distance' not in self.logstat_labels: + return np.nan + i = self.logstat_labels.index('jump-distance') + j = self.logstat_labels.index('reference-distance') + jump_distances = np.array([entry[i] for entry in self.logstat]) + reference_distances = np.array([entry[j] for entry in self.logstat]) + return np.nanmean(jump_distances > reference_distances) + + def get_info_dict(self): + """Return diagnostics of the step sampler performance. + + Returns + -------- + v: dict + the keys are: + * num_logs: number of log entries being summarized + * rejection_rate: fraction of steps rejected + * mean_scale: average value of `scale` + * mean_nsteps: average `nsteps` + * mean_distance: mean jump distance (see `Buchner+24 `_) + * frac_far_enough: fraction of jumps with sufficient distance (see `Buchner+24 `_) + * last_logstat: content of the last log entry + """ + return dict( + num_logs=len(self.logstat), + rejection_rate=np.nanmean([entry[0] for entry in self.logstat]) if len(self.logstat) > 0 else np.nan, + mean_scale=np.nanmean([entry[1] for entry in self.logstat]) if len(self.logstat) > 0 else np.nan, + mean_nsteps=np.nanmean([entry[2] for entry in self.logstat]) if len(self.logstat) > 0 else np.nan, + mean_distance=self.mean_jump_distance, + frac_far_enough=self.far_enough_fraction, + last_logstat=dict(zip(self.logstat_labels, self.logstat[-1] if len(self.logstat) > 1 else [np.nan] * len(self.logstat_labels))) + ) + + def print_diagnostic(self): + """Print diagnostic of step sampler performance.""" + if len(self.logstat) == 0: + print("diagnostic unavailable, no recorded steps found") + return + if 'jump-distance' not in self.logstat_labels or 'reference-distance' not in self.logstat_labels: + print("turn on check_nsteps in the step sampler for diagnostics") + return + frac_farenough = self.far_enough_fraction + average_distance = self.mean_jump_distance + if frac_farenough < 0.5: + advice = ': very fishy. Double nsteps and see if fraction and lnZ change)' + elif frac_farenough < 0.66: + advice = ': fishy. Double nsteps and see if fraction and lnZ change)' + else: + advice = ' (should be >50%)' + print('step sampler diagnostic: jump distance %.2f (should be >1), far enough fraction: %.2f%% %s' % ( + average_distance, frac_farenough * 100, advice)) + + def plot_jump_diagnostic_histogram(self, filename, **kwargs): + """Plot jump diagnostic histogram.""" + if len(self.logstat) == 0: + return + if 'jump-distance' not in self.logstat_labels: + return + if 'reference-distance' not in self.logstat_labels: + return + i = self.logstat_labels.index('jump-distance') + j = self.logstat_labels.index('reference-distance') + jump_distances = np.array([entry[i] for entry in self.logstat]) + reference_distances = np.array([entry[j] for entry in self.logstat]) + plt.hist(np.log10(jump_distances / reference_distances + 1e-10), **kwargs) + ylo, yhi = plt.ylim() + plt.vlines(np.log10(self.mean_jump_distance), ylo, yhi) + plt.ylim(ylo, yhi) + plt.title(self.check_nsteps or self.adaptive_nsteps) + plt.xlabel('log(relative step distance)') + plt.ylabel('Frequency') + plt.savefig(filename, bbox_inches='tight') + plt.close() + def move(self, ui, region, ndraw=1, plot=False): """Move around point ``ui``. Stub to be implemented by child classes.""" raise NotImplementedError() @@ -369,8 +856,7 @@ def adjust_outside_region(self): assert self.scale > 0 assert self.next_scale > 0 # reset chain - self.last = None, None - if self.adaptive_nsteps: + if self.adaptive_nsteps or self.check_nsteps: self.logstat.append([-1.0, self.scale, self.nsteps, np.nan, np.nan]) else: self.logstat.append([-1.0, self.scale, self.nsteps]) @@ -393,7 +879,6 @@ def adjust_accept(self, accepted, unew, pnew, Lnew, nc): """ if accepted: self.next_scale *= self.nudge - self.last = unew, Lnew self.history.append((unew.copy(), Lnew.copy())) else: self.next_scale /= self.nudge**10 @@ -410,18 +895,22 @@ def adapt_nsteps(self, region): region: MLFriends object current region """ - if not self.adaptive_nsteps: + if not (self.adaptive_nsteps or self.check_nsteps): return - elif len(self.history) < self.nsteps: + if len(self.history) < self.nsteps: # incomplete or aborted for some reason - print("not adapting, incomplete history", len(self.history), self.nsteps) + print("not adapting/checking nsteps, incomplete history", len(self.history), self.nsteps) return - # assert self.nrejects < len(self.history), (self.nsteps, self.nrejects, len(self.history)) - # assert self.nrejects <= self.nsteps, (self.nsteps, self.nrejects, len(self.history)) if self.adaptive_nsteps_needs_mean_pair_distance: assert np.isfinite(self.mean_pair_distance) ndim = region.u.shape[1] + if self.check_nsteps: + far_enough, extra_info = self.check_nsteps_function(region, self.history, self.mean_pair_distance, ndim) + self.logstat[-1] += extra_info + if not self.adaptive_nsteps: + return + far_enough, extra_info = self.adaptive_nsteps_function(region, self.history, self.mean_pair_distance, ndim) self.logstat[-1] += extra_info @@ -462,8 +951,9 @@ def finalize_chain(self, region=None, Lmin=None, Ls=None): [Lmin], ustart, ufinal, tstart, tfinal, [self.nsteps, region.maxradiussq**0.5, mean_pair_distance, iLstart, iLfinal, itstart, itfinal])]) + self.log.flush() - if self.adaptive_nsteps: + if self.adaptive_nsteps or self.check_nsteps: self.adapt_nsteps(region=region) if self.next_scale > self.scale * self.nudge**10: @@ -472,17 +962,16 @@ def finalize_chain(self, region=None, Lmin=None, Ls=None): self.next_scale = self.scale / self.nudge**10 # print("updating scale: %g -> %g" % (self.scale, self.next_scale)) self.scale = self.next_scale - self.last = None, None self.history = [] self.nrejects = 0 def new_chain(self, region=None): - """Starts a new path, reset statistics.""" + """Start a new path, reset statistics.""" self.history = [] self.nrejects = 0 def region_changed(self, Ls, region): - """React to change of region. + """React to change of region. Parameters ----------- @@ -491,7 +980,6 @@ def region_changed(self, Ls, region): Ls: array loglikelihood values of the live points """ - if self.adaptive_nsteps_needs_mean_pair_distance: self.mean_pair_distance = region.compute_mean_pair_distance() # print("region changed. new mean_pair_distance: %g" % self.mean_pair_distance) @@ -517,38 +1005,37 @@ def __next__(self, region, Lmin, us, Ls, transform, loglike, ndraw=10, plot=Fals number of draws to attempt simultaneously. plot: bool whether to produce debug plots. - tregion: WrappingEllipsoid + tregion: :py:class:`WrappingEllipsoid` optional ellipsoid in transformed space for rejecting proposals + Returns + ------- + u: None | array + newly sampled untransformed point, or None if not successful yet + p: None | array + newly sampled transformed point, or None if not successful yet + L: None | float + log-likelihood value, or None if not successful yet + nc: int + number of likelihood function calls """ # find most recent point in history conforming to current Lmin - ui, Li = self.last - if Li is not None and not Li >= Lmin: - print("wandered out of L constraint; resetting", ui[0]) - del ui, Li - ui, Li = None, None - - if Li is None and self.history: - # try to resume from a previous point above the current contour - for j, (uj, Lj) in enumerate(self.history[::-1]): - is_inside = not self.region_filter or (region.inside(uj.reshape((1,-1))) and (tregion is None or tregion.inside(transform(uj.reshape((1, -1)))))) - if Lj > Lmin and is_inside: - del ui, Li - ui, Li = uj, Lj - self.last = ui, Li - break - pass - - # select starting point - if Li is None: + for j, (_uj, Lj) in enumerate(self.history): + if not Lj > Lmin: + self.history = self.history[:j] + # print("wandered out of L constraint; reverting", ui[0]) + break + if len(self.history) > 0: + ui, Li = self.history[-1] + else: + # select starting point self.new_chain(region) # choose a new random starting point # mask = region.inside(us) # assert mask.any(), ("One of the live points does not satisfies the current region!", # region.maxradiussq, region.u, region.unormed, us) - i = np.random.randint(len(us)) + i = self.starting_point_selector(us, Ls, Lmin) self.starti = i - del Li, ui ui = us[i,:] # print("starting at", ui[0]) # assert np.logical_and(ui > 0, ui < 1).all(), ui @@ -620,10 +1107,17 @@ def move(self, ui, region, ndraw=1, plot=False): ---------- ui: array current point + region: object + ignored ndraw: int number of points to draw. + plot: bool + ignored - All other parameters are ignored. + Returns + ------- + unew: array + proposed point """ # propose in that direction direction = self.generate_direction(ui, region, scale=self.scale) @@ -631,10 +1125,14 @@ def move(self, ui, region, ndraw=1, plot=False): unew = ui.reshape((1, -1)) + jitter return unew + def CubeMHSampler(*args, **kwargs): + """Gaussian Metropolis-Hastings sampler, using unit cube.""" return MHSampler(*args, **kwargs, generate_direction=generate_random_direction) + def RegionMHSampler(*args, **kwargs): + """Gaussian Metropolis-Hastings sampler, using region.""" return MHSampler(*args, **kwargs, generate_direction=generate_region_random_direction) @@ -642,18 +1140,17 @@ class SliceSampler(StepSampler): """Slice sampler, respecting the region.""" def new_chain(self, region=None): - """Starts a new path, reset slice.""" + """Start a new path, reset slice.""" self.interval = None self.found_left = False self.found_right = False self.axis_index = 0 self.history = [] - self.last = None, None self.nrejects = 0 def adjust_accept(self, accepted, unew, pnew, Lnew, nc): - """see :py:meth:`StepSampler.adjust_accept`""" + """See :py:meth:`StepSampler.adjust_accept`.""" v, left, right, u = self.interval if not self.found_left: if accepted: @@ -676,7 +1173,6 @@ def adjust_accept(self, accepted, unew, pnew, Lnew, nc): # start with a new interval next time self.interval = None - self.last = unew, Lnew self.history.append((unew.copy(), Lnew.copy())) else: self.nrejects += 1 @@ -695,7 +1191,7 @@ def adjust_outside_region(self): self.adjust_accept(False, unew=None, pnew=None, Lnew=None, nc=0) def move(self, ui, region, ndraw=1, plot=False): - """Advance the slice sampling move. see :py:meth:`StepSampler.move`""" + """Advance the slice sampling move. see :py:meth:`StepSampler.move`.""" if self.interval is None: v = self.generate_direction(ui, region) @@ -733,7 +1229,7 @@ def move(self, ui, region, ndraw=1, plot=False): else: self.found_right = True - # adjust scale + # adjust scale to final slice length if -left > self.next_scale or right > self.next_scale: self.next_scale *= 1.1 else: @@ -757,7 +1253,7 @@ def move(self, ui, region, ndraw=1, plot=False): def CubeSliceSampler(*args, **kwargs): """Slice sampler, randomly picking region axes.""" - return SliceSampler(*args, **kwargs, generate_direction=generate_cube_oriented_direction) + return SliceSampler(*args, **kwargs, generate_direction=SequentialDirectionGenerator()) def RegionSliceSampler(*args, **kwargs): @@ -775,11 +1271,60 @@ def RegionBallSliceSampler(*args, **kwargs): return SliceSampler(*args, **kwargs, generate_direction=generate_region_random_direction) -class SequentialDirectionGenerator(object): +class SequentialDirectionGenerator: + """Sequentially proposes one parameter after the next.""" + + def __init__(self): + """Initialise.""" + self.axis_index = 0 + + def __call__(self, ui, region, scale=1): + """Choose the next axis in u-space. + + Parameters + ----------- + ui: array + current point (in u-space) + region: MLFriends object + pick random two live points for length along axis + scale: float + length of direction vector + + Returns + -------- + v: array + new direction vector (in u-space) + """ + nlive, ndim = region.u.shape + j = self.axis_index % ndim + self.axis_index = j + 1 + + v = np.zeros(ndim) + # choose pair of live points + while v[j] == 0: + i = np.random.randint(nlive) + i2 = np.random.randint(nlive - 1) + if i2 >= i: + i2 += 1 + + v[j] = (region.u[i,j] - region.u[i2,j]) * scale + + return v + + def __str__(self): + """Create string representation.""" + return type(self).__name__ + '()' + + +class SequentialRegionDirectionGenerator: + """Sequentially proposes one region axes after the next.""" + def __init__(self): + """Initialise.""" self.axis_index = 0 + def __call__(self, ui, region, scale=1): - """Iteratively choose the next axis in t-space. + """Choose the next axis in t-space. Parameters ----------- @@ -809,13 +1354,75 @@ def __call__(self, ui, region, scale=1): v *= scale / (v**2).sum()**0.5 return v + def __str__(self): + """Create string representation.""" + return type(self).__name__ + '()' + + def RegionSequentialSliceSampler(*args, **kwargs): """Slice sampler, sequentially iterating region axes.""" - return SliceSampler(*args, **kwargs, generate_direction=SequentialDirectionGenerator()) + return SliceSampler(*args, **kwargs, generate_direction=SequentialRegionDirectionGenerator()) + + +class OrthogonalDirectionGenerator: + """Orthogonalizes proposal vectors. + + Samples N proposed vectors by a provided method, then orthogonalizes + them with Gram-Schmidt (QR decomposition). + """ + + def __init__(self, generate_direction): + """Initialise. + + Parameters + ----------- + generate_direction: function + direction proposal to orthogonalize + """ + self.axis_index = 0 + self.generate_direction = generate_direction + self.directions = None + + def __str__(self): + """Return string representation.""" + return type(self).__name__ + '(generate_direction=%s)' % self.generate_direction + + def __call__(self, ui, region, scale=1): + """Return next orthogonalized vector. + + Parameters + ----------- + ui: array + current point (in u-space) + region: MLFriends object + region to use for transformation + scale: float + length of direction vector + + Returns + -------- + v: array + new direction vector (in u-space) + """ + ndim = len(ui) + if self.directions is None or self.axis_index >= ndim: + proposed_directions = np.empty((ndim, ndim)) + for i in range(ndim): + proposed_directions[i] = self.generate_direction(ui, region, scale=scale) + q, r = np.linalg.qr(proposed_directions) + self.directions = np.dot(q, np.diag(np.diag(r))) + self.axis_index = 0 + + v = self.directions[self.axis_index] + self.axis_index += 1 + return v -class SpeedVariableGenerator(object): - """Propose directions in region, but only some dimensions at a time, completely user-definable. +class SpeedVariableGenerator: + """Propose directions with only some parameters variable. + + Propose in region direction, but only include some dimensions at a time. + Completely configurable. """ def __init__(self, step_matrix, generate_direction=generate_region_random_direction): @@ -880,7 +1487,7 @@ def __call__(self, ui, region, scale=1): new direction vector """ ndim = len(ui) - + v = self.generate_direction(ui=ui, region=region, scale=scale) j = self.axis_index % self.nsteps self.axis_index = j + 1 @@ -897,19 +1504,22 @@ def SpeedVariableRegionSliceSampler(step_matrix, *args, **kwargs): Updates only some dimensions at a time, completely user-definable. """ - - - return SliceSampler(*args, **kwargs, - nsteps=kwargs.pop('nsteps', len(step_matrix)), + generate_direction = kwargs.pop('generate_direction', generate_region_random_direction) + nsteps = kwargs.pop('nsteps', len(step_matrix)) + return SliceSampler( + *args, **kwargs, + nsteps=nsteps, generate_direction=SpeedVariableGenerator( step_matrix=step_matrix, - generate_direction=kwargs.pop('generate_direction', generate_region_random_direction) + generate_direction=generate_direction ) ) def ellipsoid_bracket(ui, v, ellipsoid_center, ellipsoid_inv_axes, ellipsoid_radius_square): - """ For a line from ui in direction v through an ellipsoid + """Find line-ellipsoid intersection points. + + For a line from ui in direction v through an ellipsoid centered at ellipsoid_center with axes matrix ellipsoid_inv_axes, return the lower and upper intersection parameter. @@ -922,7 +1532,7 @@ def ellipsoid_bracket(ui, v, ellipsoid_center, ellipsoid_inv_axes, ellipsoid_rad ellipsoid_center: array center of the ellipsoid ellipsoid_inv_axes: array - ellipsoid axes matrix, as computed by :class:WrappingEllipsoid + ellipsoid axes matrix, as computed by :py:class:`WrappingEllipsoid` ellipsoid_radius_square: float square of the ellipsoid radius @@ -950,7 +1560,9 @@ def ellipsoid_bracket(ui, v, ellipsoid_center, ellipsoid_inv_axes, ellipsoid_rad def crop_bracket_at_unit_cube(ui, v, left, right, epsilon=1e-6): - """A line segment from *ui* in direction *v* from t between *left* <= 0 <= *right* + """Find line-cube intersection points. + + A line segment from *ui* in direction *v* from t between *left* <= 0 <= *right* will be truncated by the unit cube. Returns the bracket and whether cropping was applied. Parameters @@ -1025,517 +1637,3 @@ def crop_bracket_at_unit_cube(ui, v, left, right, epsilon=1e-6): assert left <= 0 <= right, (left, right) return left, right, cropped_left, cropped_right - -def _prepare_steps( - nsteps_done, nsteps, directions, ndraw, - current_interval, loglike, transform, region, ndim, region_filter, - Lmin, verbose, -): - point_sequence = [] - point_expectation = [] - intervals = [] - nsteps_prepared = 0 - while nsteps_prepared + nsteps_done < nsteps and len(point_sequence) < ndraw: - if verbose: - print("loop:", nsteps_prepared, nsteps_done, 'of', nsteps) - v = directions[nsteps_done + nsteps_prepared] - if verbose: - print("direction:", v) - if len(point_sequence) == 0: - ucurrent, left, right = current_interval - assert (ucurrent >= 0).all(), ucurrent - assert (ucurrent <= 1).all(), ucurrent - assert region.inside_ellipsoid(ucurrent.reshape((1, ndim))), ( - 'cannot start from outside ellipsoid!', region.inside_ellipsoid(ucurrent.reshape((1, ndim)))) - if region_filter: - assert region.inside(ucurrent.reshape((1, ndim))), ( - 'cannot start from outside region!', region.inside(ucurrent.reshape((1, ndim)))) - assert loglike(transform(ucurrent.reshape((1, ndim)))) >= Lmin, ( - 'cannot start from outside!', loglike(transform(ucurrent.reshape((1, ndim)))), Lmin) - else: - left, right = None, None - assert (ucurrent >= 0).all(), ucurrent - assert (ucurrent <= 1).all(), ucurrent - if verbose: - print("preparing step: %d from %s" % (nsteps_prepared + nsteps_done, ucurrent)) - - if left is None or right is None: - # in each, find the end points using the expanded ellipsoid - assert region.inside_ellipsoid(ucurrent.reshape((1, ndim))), ('current point outside ellipsoid!') - left, right = ellipsoid_bracket(ucurrent, v, region.ellipsoid_center, region.ellipsoid_inv_axes, region.enlarge) - left, right, _, _ = crop_bracket_at_unit_cube(ucurrent, v, left, right) - assert (ucurrent + v * left <= 1).all(), ( - ucurrent, v, region.ellipsoid_center, region.ellipsoid_inv_axes, region.ellipsoid_invcov, region.enlarge) - assert (ucurrent + v * right <= 1).all(), ( - ucurrent, v, region.ellipsoid_center, region.ellipsoid_inv_axes, region.ellipsoid_invcov, region.enlarge) - assert (ucurrent + v * left >= 0).all(), ( - ucurrent, v, region.ellipsoid_center, region.ellipsoid_inv_axes, region.ellipsoid_invcov, region.enlarge) - assert (ucurrent + v * right >= 0).all(), ( - ucurrent, v, region.ellipsoid_center, region.ellipsoid_inv_axes, region.ellipsoid_invcov, region.enlarge) - - assert left <= 0 <= right, (left, right) - if verbose: - print(" ellipsoid bracket found:", left, right) - - while True: - # sample in each a point until presumed success: - assert region.inside_ellipsoid(ucurrent.reshape((1, ndim))), ('current point outside ellipsoid!') - t = np.random.uniform(left, right) - unext = ucurrent + v * t - assert (unext >= 0).all(), unext - assert (unext <= 1).all(), unext - assert region.inside_ellipsoid(unext.reshape((1, ndim))), ('proposal landed outside ellipsoid!', t, left, right) - - # compute distance vector to center - d = unext - region.ellipsoid_center - # distance in normalised coordates: vector . matrix . vector - # where the matrix is the ellipsoid inverse covariance - r = np.einsum('j,jk,k->', d, region.ellipsoid_invcov, d) - if verbose: - print(" proposed slice point", t, r) - - likely_inside = r <= 1 - if not likely_inside and r <= region.enlarge: - # The exception is, when a point is between projected ellipsoid center and current point - # then it is also likely inside (if still inside the ellipsoid) - - # project ellipsoid center onto line - # region.ellipsoid_center = ucurrent + tc * v - tc = np.dot(region.ellipsoid_center - ucurrent, v) - # current point is at 0 by definition - if 0 < t < tc or tc < t < 0: - if verbose: - print(" proposed point is further inside than current point") - likely_inside = True - # print(" proposed point %.3f is going towards center %.3f" % (t, tc)) - # else: - # print(" proposed point %.3f is going away from center %.3f" % (t, tc)) - else: - # another exception is that points very close to the current point - # are very likely also inside - # to find that out, project all live points on the line - tall = np.einsum('ij,j->i', region.u - ucurrent, v) - # find the range and identify a small part of it - epsilon_nearby = 1e-6 - if tc < (tall.max() - tall.min()) * epsilon_nearby: - likely_inside = True - if verbose: - print(" proposed point is very nearby") - - if verbose: - print(" proposed point %s (%f) is likely %s (r=%f)" % (unext, t, 'inside' if likely_inside else 'outside', r)) - intervals.append((nsteps_prepared, ucurrent, v, left, right, t)) - point_sequence.append(unext) - point_expectation.append(likely_inside) - # If point radius in ellipsoid is <1, presume that it will be successful - if likely_inside: - nsteps_prepared += 1 - ucurrent = unext - assert region.inside_ellipsoid(ucurrent.reshape((1, ndim))), ('current point outside ellipsoid!') - break - - # Else, presume it will be unsuccessful, and sample another point - # shrink interval - if t > 0: - right = t - else: - left = t - - assert len(point_sequence) == len(point_expectation) - assert len(point_sequence) == len(intervals) - assert nsteps_prepared <= len(point_sequence) - - assert len(point_sequence) > 0, (len(point_sequence), ndraw, nsteps_prepared, nsteps_done, nsteps) - - if verbose: - print("proposed sequence:", point_sequence) - print("expectations:", point_expectation) - - return np.array(point_sequence, dtype=float), np.array(point_expectation, dtype=bool), intervals, nsteps_prepared - - -def _evaluate_with_filter( - region_filter, loglike, transform, Lmin, region, tregion, - point_sequence, point_expectation, - verbose -): - truncated = False - # region-filter, transform, tregion-filter, and evaluate the likelihood - if region_filter: - mask_inside = region.inside(point_sequence) - # identify first point that was expected to be inside, but was marked outside-of-region - i = np.where(np.logical_and(point_expectation, ~mask_inside))[0] - if verbose: - print("region filter says:", mask_inside, i) - if len(i) > 0: - imax = i[0] + 1 - # truncate there - point_sequence = point_sequence[:imax] - point_expectation = point_expectation[:imax] - mask_inside = mask_inside[:imax] - truncated |= True - del imax - if not mask_inside.any(): - return None - else: - mask_inside = None - - t_point_sequence = transform(point_sequence) - if region_filter and tregion is not None: - tmask = tregion.inside(t_point_sequence) - # identify first point that was expected to be inside, but was marked outside-of-region - i = np.where(np.logical_and(point_expectation, ~tmask))[0] - if verbose: - print("tregion filter says:", tmask, i) - mask_inside[~tmask] = False - del tmask - if len(i) > 0: - imax = i[0] + 1 - # truncate there - point_sequence = point_sequence[:imax] - point_expectation = point_expectation[:imax] - t_point_sequence = t_point_sequence[:imax] - mask_inside = mask_inside[:imax] - truncated |= True - del imax - if not mask_inside.any(): - return None - - # we expect the last point to be an accept, otherwise we would not terminate the sequence - assert point_expectation[-1] - if region_filter: - # set filtered ones to -np.inf - L = np.ones(len(t_point_sequence)) * -np.inf - nc = mask_inside.sum() - L[mask_inside] = loglike(t_point_sequence[mask_inside,:]) - else: - nc = len(point_sequence) - L = loglike(t_point_sequence) - Lmask = L > Lmin - - i = np.where(point_expectation != Lmask)[0] - if verbose: - print("reality:", Lmask) - print("difference:", point_expectation == Lmask) - return point_sequence, t_point_sequence, L, Lmask, i, nc, truncated - -class AHARMSampler(StepSampler): - """Accelerated hit-and-run/slice sampler, vectorised. - - Uses region ellipsoid to propose a sequence of points - on a randomly drawn line. - - (in development) - """ - - def __init__( - self, nsteps, adaptive_nsteps=False, max_nsteps=1000, - region_filter=False, log=False, direction=generate_region_random_direction, - orthogonalise=True, - ): - """Initialise vectorised hit-and-run/slice sampler. - - Parameters - ----------- - nsteps: int - number of accepted steps until the sample is considered independent. - - adaptive_nsteps: False, 'proposal-distance', 'move-distance' - Select a strategy to adapt the number of steps. The strategies - make sure that: - - * 'move-distance' (recommended): distance between - start point and final position exceeds the mean distance - between pairs of live points. - * 'move-distance-midway': distance between - start point and position in the middle of the chain - exceeds the mean distance between pairs of live points. - - max_nsteps: int - Maximum number of steps the adaptive_nsteps can reach. - - region_filter: bool - if True, use region to check if a proposed point can be inside - before calling likelihood. - - direction: function - function that draws slice direction given a point and - the current region. - - orthogonalise: bool - If true, make subsequent proposed directions orthogonal - to each other. - - log: file - log file for sampler statistics, such as acceptance rate, - proposal scale, number of steps, jump distance and distance - between live points - - """ - self.history = [] - self.nsteps = nsteps - self.nrejects = 0 - self.max_nsteps = max_nsteps - self.last = None, None - self.generate_direction = direction - adaptive_nsteps_options = [ - False, - 'move-distance', 'move-distance-midway', - ] - - if adaptive_nsteps not in adaptive_nsteps_options: - raise ValueError("adaptive_nsteps must be one of: %s, not '%s'" % (adaptive_nsteps_options, adaptive_nsteps)) - self.adaptive_nsteps = adaptive_nsteps - self.region_filter = region_filter - self.log = log - self.adaptive_nsteps_needs_mean_pair_distance = False - self.nsteps_nudge = 1.01 - self.orthogonalise = orthogonalise - - self.logstat = [] - self.logstat_labels = ['rejection_rate', 'steps'] - if adaptive_nsteps: - self.logstat_labels += ['jump-distance', 'reference-distance'] - - def __next__(self, region, Lmin, us, Ls, transform, loglike, ndraw=1024, plot=False, tregion=None, verbose=False): - """Get next point. - - Parameters - ---------- - region: MLFriends - region. - Lmin: float - loglikelihood threshold - us: array of vectors - current live points - Ls: array of floats - current live point likelihoods - transform: function - transform function - loglike: function - loglikelihood function - ndraw: int - number of draws to attempt simultaneously. - plot: bool - whether to produce debug plots. - tregion: WrappingEllipsoid - optional ellipsoid in transformed space for rejecting proposals - - """ - # find most recent point in history conforming to current Lmin - ui, Li = self.last - if Li is not None and not Li >= Lmin: - print("wandered out of L constraint; resetting", ui[0]) - ui, Li = None, None - - if ui is not None and not region.inside_ellipsoid(ui.reshape((1, -1))): - print("wandered out of ellipsoid; resetting", ui[0]) - ui, Li = None, None - - if Li is None and self.history: - # try to resume from a previous point above the current contour - for j, (uj, Lj) in enumerate(self.history[::-1]): - if Lj > Lmin and region.inside(uj.reshape((1,-1))) and (tregion is None or tregion.inside(transform(uj.reshape((1, -1))))): - ui, Li = uj, Lj - # print("recovering at point %d/%d " % (j+1, len(self.history))) - self.last = ui, Li - - # pj = transform(uj.reshape((1, -1))) - # Lj2 = loglike(pj)[0] - # assert Lj2 > Lmin, (Lj2, Lj, uj, pj) - assert region.inside_ellipsoid(ui.reshape((1, -1))) - - break - pass - - # select starting point - ndim = us.shape[1] - if Li is None: - self.directions = None - - self.history = [] - self.last = None, None - self.nrejects = 0 - - # choose a new random starting point - i = np.random.randint(len(us)) - self.starti = i - ui = us[i,:] - assert region.inside_ellipsoid(ui.reshape((1, -1))) - assert np.logical_and(ui > 0, ui < 1).all(), ui - Li = Ls[i] - self.history.append((ui.copy(), Li.copy())) - del i - print("starting at", ui) - # set initially nleft = nsteps - self.nsteps_done = 0 - - # generate nsteps directions - self.directions = [] - for i in range(self.nsteps): - v = self.generate_direction(ui, region) - self.directions.append(v) - self.directions = np.array(self.directions) - - if verbose: - print("directions:", self.directions) - if self.orthogonalise: - # orthogonalise relative to this previous direction - for i in range(self.nsteps // ndim): - # go back only ndim steps, then start fresh - self.directions[i * ndim:(i + 1) * ndim], _ = np.linalg.qr(self.directions[i * ndim:(i + 1) * ndim]) - - assert (ui >= 0).all(), ui - assert (ui <= 1).all(), ui - self.current_interval = ui, None, None - if self.region_filter: - assert region.inside(ui.reshape((1, ndim))), ('cannot start from outside region!', region.inside(ui.reshape((1, ndim)))) - - del ui - nc = 0 - while True: - # prepare a sequence of points until nsteps are reached - point_sequence, point_expectation, intervals, nsteps_prepared = _prepare_steps( - self.nsteps_done, self.nsteps, self.directions, ndraw, - self.current_interval, loglike, transform, region, ndim, self.region_filter, - Lmin, verbose - ) - point_sequence, t_point_sequence, L, Lmask, indices_deviating, nc_here, truncated = _evaluate_with_filter( - self.region_filter, loglike, transform, Lmin, region, tregion, - point_sequence, point_expectation, - verbose - ) - del point_expectation - nc += nc_here - - self.nrejects += (~Lmask).sum() - #print("calling likelihood with %5d prepared points, accepted:" % ( - # len(point_sequence)), '=' * (i[0] + Lmask[i[0]] * 1 if len(i) > 0 else len(Lmask))) - # identify first point that was unexpected - any_deviating = len(indices_deviating) > 0 - if any_deviating and nsteps_prepared + self.nsteps_done == self.nsteps: - # everything according to prediction. - if verbose: - print("everything according to prediction and done") - # done, return last point - for ui, Li in zip(point_sequence[Lmask], L[Lmask]): - self.history.append((ui, Li)) - self.finalize_chain(region=region, Lmin=Lmin, Ls=Ls) - return point_sequence[-1], t_point_sequence[-1], L[-1], nc - elif any_deviating: - # everything according to prediction. - if verbose: - print("everything according to prediction") - # continue from last point - for ui, Li in zip(point_sequence[Lmask], L[Lmask]): - self.history.append((ui, Li)) - self.nsteps_done += nsteps_prepared - assert self.nsteps_done == len(self.history), (self.nsteps_done, len(self.history)) - nsteps_prepared, ucurrent, v, left, right, t = intervals[-1] - assert (ucurrent >= 0).all(), ucurrent - assert (ucurrent <= 1).all(), ucurrent - self.current_interval = ucurrent, None, None - if self.region_filter: - assert region.inside(ucurrent.reshape((1, ndim))), ('suggested point outside region!', region.inside(ucurrent.reshape((1, ndim)))) - else: - # point i unexpectedly inside or outside - imax = indices_deviating[0] - for ui, Li in zip(point_sequence[:imax][Lmask[:imax]], L[:imax][Lmask[:imax]]): - self.history.append((ui, Li)) - nsteps_prepared, ucurrent, v, left, right, t = intervals[imax] - if self.region_filter: - assert region.inside(ucurrent.reshape((1, ndim))), ('suggested point outside region!', region.inside(ucurrent.reshape((1, ndim)))) - assert (ucurrent >= 0).all(), ucurrent - assert (ucurrent <= 1).all(), ucurrent - if point_expectation[imax]: - if verbose: - print("following prediction until %d, which was unexpectedly rejected" % imax) - # expected point to lie inside, but rejected - # need to repair interval - self.nsteps_done += nsteps_prepared - assert self.nsteps_done + 1 == len(self.history), (self.nsteps_done, len(self.history)) - if t > 0: - right = t - else: - left = t - if verbose: - print("%d steps done, continuing from unexpected outside point" % self.nsteps_done, imax, point_sequence[imax], "interval:", t) - self.current_interval = ucurrent, left, right - else: - if verbose: - print("following prediction until %d, which was unexpectedly accepted" % imax) - if imax == len(point_sequence) - 1 and truncated: - assert False - ucurrent = point_sequence[imax] - if self.region_filter: - assert region.inside(ucurrent.reshape((1, ndim))), ('accepted point outside region!', region.inside(ucurrent.reshape((1, ndim)))) - # expected point to lie outside, but actually inside - # adopt as point and continue - # print(len(self.history), self.nsteps_done, nsteps_prepared, Lmask[:imax].sum()) - self.nsteps_done += nsteps_prepared + 1 - self.history.append((ucurrent.copy(), L[imax])) - assert self.nsteps_done + 1 == len(self.history), (self.nsteps_done, len(self.history)) - self.current_interval = ucurrent, None, None - if self.nsteps_done == self.nsteps: - # last point was inside, so we are actually done there - self.finalize_chain(region=region, Lmin=Lmin, Ls=Ls) - return point_sequence[-1], t_point_sequence[-1], L[-1], nc - else: - if verbose: - print("%d steps done, continuing from unexpected inside point" % self.nsteps_done, imax, point_sequence[imax]) - - # need to exit here to only do one likelihood evaluation - # per function call - if verbose: - print("breaking") - break - - # do not have a independent sample yet - return None, None, None, nc - - def region_changed(self, Ls, region): - assert region.inside_ellipsoid(region.u).all() - ui, Li = self.last - if ui is not None and not region.inside(ui.reshape((1, -1))): - print("wandered out of ellipsoid; resetting", ui[0]) - self.last = None, None - - def finalize_chain(self, region=None, Lmin=None, Ls=None): - """Store chain statistics and adapt proposal.""" - self.logstat.append([self.nrejects / self.nsteps, self.nsteps]) - if self.log: - ustart, Lstart = self.history[0] - ufinal, Lfinal = self.history[-1] - # mean_pair_distance = region.compute_mean_pair_distance() - mean_pair_distance = np.nan - tstart, tfinal = region.transformLayer.transform(np.vstack((ustart, ufinal))) - # L index of start and end - # Ls_sorted = np.sort(Ls) - iLstart = np.sum(Ls > Lstart) - iLfinal = np.sum(Ls > Lfinal) - # nearest neighbor index of start and end - itstart = np.argmin((region.unormed - tstart.reshape((1, -1)))**2) - itfinal = np.argmin((region.unormed - tfinal.reshape((1, -1)))**2) - np.savetxt(self.log, [_listify( - [Lmin], ustart, ufinal, tstart, tfinal, - [self.nsteps, region.maxradiussq**0.5, mean_pair_distance, - iLstart, iLfinal, itstart, itfinal])]) - - if self.adaptive_nsteps: - self.adapt_nsteps(region=region) - - self.last = None, None - self.history = [] - self.nrejects = 0 - - def generate_new_interval(self, ui, region): - v = self.generate_direction(ui, region) - assert region.inside_ellipsoid(ui.reshape((1, -1))) - assert (ui > 0).all(), ui - assert (ui < 1).all(), ui - - # use region ellipsoid to identify limits - # rotate line so that ellipsoid is a sphere - left, right = ellipsoid_bracket(ui, v, region.ellipsoid_center, region.ellipsoid_inv_axes, region.enlarge) - left, right, _, _ = crop_bracket_at_unit_cube(ui, v, left, right) - self.interval = (v, left, right, 0) diff --git a/ultranest/store.py b/ultranest/store.py index 50311515..f74623fb 100644 --- a/ultranest/store.py +++ b/ultranest/store.py @@ -1,4 +1,7 @@ -"""Storage for nested sampling points. +# noqa: D400 D205 +""" +Storage for nested sampling points +----------------------------------- The information stored is a table with @@ -7,13 +10,16 @@ """ -from __future__ import print_function, division -import numpy as np -import warnings +from __future__ import division, print_function + +import contextlib import os +import warnings + +import numpy as np -class NullPointStore(object): +class NullPointStore: """No storage.""" def __init__(self, ncols): @@ -46,7 +52,7 @@ def pop(self, Lmin): return None, None -class FilePointStore(object): +class FilePointStore: """Base class for storing points in a file.""" def reset(self): @@ -69,6 +75,11 @@ def flush(self): def pop(self, Lmin): """Request from the storage a point sampled from <= Lmin with L > Lmin. + Parameters + ---------- + Lmin: float + loglikelihood threshold + Returns ------- index: int @@ -114,7 +125,7 @@ def __init__(self, filepath, ncols): self.nrows = 0 self.stack_empty = True self._load(filepath) - self.fileobj = open(filepath, 'ab') + self.fileobj = open(filepath, 'ab') # noqa: SIM115 self.fmt = '%.18e' self.delimiter = '\t' @@ -122,18 +133,16 @@ def _load(self, filepath): """Load from data file *filepath*.""" stack = [] if os.path.exists(filepath): - try: - for line in open(filepath): + with contextlib.suppress(IOError), open(filepath) as f: + for line in f: try: parts = [float(p) for p in line.split()] if len(parts) != self.ncols: - warnings.warn("skipping lines in '%s' with different number of columns" % (filepath)) + warnings.warn("skipping lines in '%s' with different number of columns" % (filepath), stacklevel=3) continue stack.append(parts) except ValueError: - warnings.warn("skipping unparsable line in '%s'" % (filepath)) - except IOError: - pass + warnings.warn("skipping unparsable line in '%s'" % (filepath), stacklevel=3) self.stack = list(enumerate(stack)) self.ncalls = len(self.stack) @@ -192,7 +201,7 @@ def _load(self): """Load from data file.""" if 'points' not in self.fileobj: self.fileobj.create_dataset( - 'points', dtype=np.float, + 'points', dtype=float, shape=(0, self.ncols), maxshape=(None, self.ncols)) self.nrows, ncols = self.fileobj['points'].shape diff --git a/ultranest/utils.py b/ultranest/utils.py index 0abbb657..8156160c 100644 --- a/ultranest/utils.py +++ b/ultranest/utils.py @@ -1,12 +1,18 @@ -"""Utility functions for logging and statistics.""" +# noqa: D400 D205 +""" +Utility functions for logging and statistics +-------------------------------------------- +""" -from __future__ import print_function, division +from __future__ import division, print_function + +import errno import logging -import sys import os +import sys + import numpy as np from numpy import pi -import errno def create_logger(module_name, log_dir=None, level=logging.INFO): @@ -52,7 +58,7 @@ def create_logger(module_name, log_dir=None, level=logging.INFO): formatter = logging.Formatter('[{}] %(message)s'.format(module_name)) handler.setFormatter(formatter) logger.addHandler(handler) - + logger.addHandler(logging.NullHandler()) return logger @@ -130,7 +136,9 @@ def vectorized(args): """Vectorized version of function.""" return np.asarray([function(arg) for arg in args]) - vectorized.__name__ = function.__name__ + # give a user-friendly name to the vectorized version of the function + # getattr works around methods, which do not have __name__ + vectorized.__name__ = getattr(function, '__name__', vectorized.__name__) return vectorized @@ -152,6 +160,8 @@ def resample_equal(samples, weights, rstate=None): Shape is (N, ...), with N the number of samples. weights : `~numpy.ndarray` Weight of each sample. Shape is (N,). + rstate : `~numpy.random.RandomState` + random number generator. If not provided, numpy.random is used. Returns ------- @@ -191,7 +201,7 @@ def resample_equal(samples, weights, rstate=None): # make N subdivisions, and choose positions with a consistent random offset positions = (rstate.random() + np.arange(N)) / N - idx = np.zeros(N, dtype=int) + idx = np.zeros(N, dtype=np.int_) cumulative_sum = np.cumsum(weights) i, j = 0, 0 while i < N: @@ -211,7 +221,7 @@ def listify(*args): Parameters ---------- - args: iterable + *args: iterable Lists to concatenate. Returns @@ -411,7 +421,7 @@ def verify_gradient(ndim, transform, loglike, gradient, verbose=False, combinati eps = 1e-6 N = 10 - for i in range(N): + for _i in range(N): u = np.random.uniform(2 * eps, 1 - 2 * eps, size=(1, ndim)) theta = transform(u) if verbose: @@ -441,3 +451,52 @@ def verify_gradient(ndim, transform, loglike, gradient, verbose=False, combinati print("expectation was L=", Lexpected, ", given", Lref, grad, eps) assert np.allclose(Lprime, Lexpected, atol=0.1 / ndim), \ (u, uprime, theta, thetaprime, grad, eps * grad / L, L, Lprime, Lexpected) + + +def distributed_work_chunk_size(num_total_tasks, mpi_rank, mpi_size): + """ + Divide tasks uniformly. + + Computes the number of tasks for process number `mpi_rank`, so that + `num_total_tasks` tasks are spread uniformly among `mpi_size` processes. + + Parameters + ---------- + num_total_tasks : int + total number of tasks to be split + mpi_rank : int + process id + mpi_size : int + total number of processes + + Returns + ------- + chunk_size: int + number of tasks for process number `mpi_rank` + """ + return (num_total_tasks + mpi_size - 1 - mpi_rank) // mpi_size + + +def submasks(mask, *masks): + """ + Get indices for a submasked array. + + Returns indices, so that a[indices] is equivalent to a[mask][mask1][mask2]. + + Parameters + ---------- + mask : np.array(dtype=bool) + selection of some array + *masks : list of np.array(dtype=bool) + each further mask is a subselection + + Returns + ------- + indices : np.array(dtype=np.int_) + indices which select the subselection in the original array + + """ + indices, = np.where(mask) + for othermask in masks: + indices = indices[othermask] + return indices diff --git a/ultranest/viz.py b/ultranest/viz.py index 67b3a3f0..b2309903 100644 --- a/ultranest/viz.py +++ b/ultranest/viz.py @@ -1,14 +1,23 @@ -"""Visual impression of current exploration.""" +# noqa: D400 D205 +""" +Live point visualisations +------------------------- -from __future__ import print_function, division +Gives a live impression of current exploration. +This is powerful because the user can abort partial runs if the fit +converges to unreasonable values. + +""" + +from __future__ import division, print_function -import sys import shutil -from numpy import log10 -import numpy as np import string +import sys from xml.sax.saxutils import escape as html_escape +import numpy as np +from numpy import log10 clusteridstrings = ['%d' % i for i in range(10)] + list(string.ascii_uppercase) + list(string.ascii_lowercase) @@ -24,7 +33,7 @@ def round_parameterlimits(plo, phi, paramlimitguess=None): """Guess the current parameter range. Parameters - ----------- + ---------- plo: array of floats for each parameter, current minimum value phi: array of floats @@ -40,7 +49,6 @@ def round_parameterlimits(plo, phi, paramlimitguess=None): for each parameter, rounded maximum value formats: array of float tuples for each parameter, string format for representing it. - """ with np.errstate(divide='ignore'): expos = log10(np.abs([plo, phi])) @@ -90,7 +98,7 @@ def nicelogger(points, info, region, transformLayer, region_fresh=False): region: MLFriends Current region. - transformLayer: ScaleLayer or AffineLayer + transformLayer: ScaleLayer or AffineLayer or MaxPrincipleGapAffineLayer Current transformLayer (for clustering information). region_fresh: bool Whether the region was just updated. @@ -107,9 +115,9 @@ def nicelogger(points, info, region, transformLayer, region_fresh=False): plo_rounded, phi_rounded, paramformats = round_parameterlimits(plo, phi, paramlimitguess=info.get('paramlims')) if sys.stderr.isatty() and hasattr(shutil, 'get_terminal_size'): - columns, _rows = shutil.get_terminal_size(fallback=(80, 25)) + columns, _ = shutil.get_terminal_size(fallback=(80, 25)) else: - columns, _rows = 80, 25 + columns, _ = 80, 25 paramwidth = max([len(pname) for pname in paramnames]) width = columns - 23 - paramwidth @@ -131,6 +139,16 @@ def nicelogger(points, info, region, transformLayer, region_fresh=False): ("Quality: correlation length: %d (%s)" % (info['order_test_correlation'], '+' if info['order_test_direction'] >= 0 else '-')) if np.isfinite(info['order_test_correlation']) else "Quality: ok", ) + if info.get('stepsampler_info', {}).get('num_logs', 0) > 0: + stepsampler_info = dict(info['stepsampler_info']) + stepsampler_info['frac_far_enough'] *= 100 + if 'mean_distance' in stepsampler_info: + print(( + 'Step sampler performance: %(rejection_rate).1f rej/step, %(mean_nsteps)d steps/it, ' + 'rel jump distance: %(mean_distance).2f (should be >1), %(frac_far_enough).2f%% (should be >50%%)') % stepsampler_info + ) + else: + print() print() if ndim == 1: @@ -196,9 +214,9 @@ def isnotebook(): """Check if running in a Jupyter notebook.""" try: shell = get_ipython().__class__.__name__ - if shell == 'ZMQInteractiveShell': + if shell == 'ZMQInteractiveShell': # noqa: SIM103 return True # Jupyter notebook or qtconsole - elif shell == 'TerminalInteractiveShell': + elif shell == 'TerminalInteractiveShell': # noqa: SIM103 return False # Terminal running IPython else: return False # Other type (?) @@ -206,7 +224,7 @@ def isnotebook(): return False # Probably standard Python interpreter -class LivePointsWidget(object): +class LivePointsWidget: """ Widget for ipython and jupyter notebooks. @@ -230,8 +248,8 @@ def initialize(self, paramnames, width): number of html table columns. """ - from ipywidgets import HTML, VBox, Layout, GridspecLayout from IPython.display import display + from ipywidgets import HTML, GridspecLayout, Layout, VBox grid = GridspecLayout(len(paramnames), width + 3) self.laststatus = [] @@ -266,7 +284,7 @@ def __call__(self, points, info, region, transformLayer, region_fresh=False): region: MLFriends Current region. - transformLayer: ScaleLayer or AffineLayer + transformLayer: ScaleLayer or AffineLayer or MaxPrincipleGapAffineLayer Current transformLayer (for clustering information). region_fresh: bool Whether the region was just updated. @@ -292,7 +310,8 @@ def __call__(self, points, info, region, transformLayer, region_fresh=False): if self.grid is None: self.initialize(paramnames, width) - indices = ((p - plo_rounded) * width / (phi_rounded - plo_rounded).reshape((1, -1))).astype(int) + with np.errstate(invalid="ignore"): + indices = ((p - plo_rounded) * width / (phi_rounded - plo_rounded).reshape((1, -1))).astype(int) indices[indices >= width] = width - 1 indices[indices < 0] = 0 ndim = len(plo) @@ -306,6 +325,16 @@ def __call__(self, points, info, region, transformLayer, region_fresh=False): (" | Quality: correlation length: %d (%s)" % (info['order_test_correlation'], '+' if info['order_test_direction'] >= 0 else '-')) if np.isfinite(info['order_test_correlation']) else " | Quality: ok") + if info.get('stepsampler_info', {}).get('num_logs', 0) > 0: + stepsampler_info = dict(info['stepsampler_info']) + stepsampler_info['frac_far_enough'] *= 100 + if 'mean_distance' in stepsampler_info: + labeltext += ( + "
    " + 'Step sampler performance: %(rejection_rate).1f%% rej/step, %(mean_nsteps)d steps/it' + 'mean rel jump distance: %(mean_distance).2f (should be >1), %(frac_far_enough).2f%% (should be >50%%)' + ) % stepsampler_info + if ndim == 1: pass elif ndim == 2 and spearman is not None: @@ -327,7 +356,7 @@ def __call__(self, points, info, region, transformLayer, region_fresh=False): 'positive degeneracy' if rho[i,j] > 0 else 'negative degeneracy', param2, param, rho[i,j])) - for i, (param, fmt) in enumerate(zip(paramnames, paramformats)): + for i, (_param, fmt) in enumerate(zip(paramnames, paramformats)): if nmodes == 1: line = [' ' for _ in range(width)] for j in np.unique(indices[:,i]):