├── docs ├── .gitignore ├── Makefile └── source │ ├── conf.py │ ├── _static │ └── logo.svg │ ├── api.md │ └── index.md ├── requirements.txt ├── qubolite.png ├── upload.sh ├── qubolite ├── __init__.py ├── _misc.py ├── bounds.py ├── bitvec.py ├── _heuristics.py ├── embedding.py ├── _c_utils.c ├── sampling.py ├── solving.py ├── assignment.py ├── preprocessing.py └── qubo.py ├── setup.py ├── .github └── workflows │ └── docs.yml ├── pyproject.toml ├── .gitignore └── README.md /docs/.gitignore: -------------------------------------------------------------------------------- 1 | build/ 2 | source/*.rst -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | numpy>=1.23.5 2 | -------------------------------------------------------------------------------- /qubolite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smuecke/qubolite/HEAD/qubolite.png -------------------------------------------------------------------------------- /upload.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | echo "Uploading to PyPi" 3 | python3 -m pip install --upgrade build twine 4 | #python3.10 setup.py sdist 5 | python3 -m build --sdist 6 | python3 -m twine upload dist/qubolite-*.tar.gz --repository pypi 7 | echo "Done" 8 | -------------------------------------------------------------------------------- /qubolite/__init__.py: -------------------------------------------------------------------------------- 1 | from .qubo import ( 2 | qubo, 3 | is_qubo_like, 4 | to_triu_form 5 | ) 6 | 7 | from . import ( 8 | assignment, 9 | bitvec, 10 | bounds, 11 | embedding, 12 | preprocessing, 13 | sampling, 14 | solving) -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from os.path import join 3 | from platform import system 4 | 5 | from setuptools import Extension, setup 6 | from numpy import get_include as numpy_incl 7 | 8 | 9 | SYSTEM = system() 10 | if SYSTEM == 'Windows': 11 | C_LINK_FLAGS = [] 12 | C_COMP_FLAGS = ['/O2', '/openmp'] 13 | 14 | elif SYSTEM == 'Darwin': # clang flags for macos (without omp) 15 | C_LINK_FLAGS = [] 16 | C_COMP_FLAGS = ['-O3'] 17 | 18 | else: # GCC flags for Linux 19 | C_LINK_FLAGS = ['-fopenmp'] 20 | C_COMP_FLAGS = ['-O3', '-fopenmp', '-march=native'] 21 | 22 | NP_INCL = numpy_incl() 23 | 24 | setup(ext_modules=[ 25 | Extension( 26 | name='_c_utils', 27 | sources=['qubolite/_c_utils.c'], 28 | libraries=['npymath','npyrandom'], 29 | include_dirs=[NP_INCL], 30 | library_dirs=[ 31 | join(NP_INCL, '..', 'lib'), 32 | join(NP_INCL, '..', '..', 'random', 'lib')], # no official way to retrieve these directories 33 | extra_compile_args=C_COMP_FLAGS, 34 | extra_link_args=C_LINK_FLAGS)]) 35 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: build-and-deploy-docs 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | # build and push documentation to gh-pages 10 | docs: 11 | name: Documentation 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v1 15 | - name: Build documentation 16 | uses: ammaraskar/sphinx-action@master 17 | with: 18 | pre-build-command: | 19 | python -m pip install --upgrade pip setuptools wheel 20 | python -m pip install sphinx-book-theme myst-parser 21 | docs-folder: "docs/" 22 | build-command: "sphinx-build -M html source/ build/ -a -j auto" 23 | - name: Publish documentation 24 | run: | 25 | git clone ${{ github.server_url }}/${{ github.repository }}.git --branch gh-pages --single-branch __gh-pages/ 26 | cp -r docs/build/html/* __gh-pages/ 27 | cd __gh-pages/ 28 | git config --local user.email "action@github.com" 29 | git config --local user.name "GitHub Action" 30 | git add . 31 | git commit -am "Documentation based on ${{ github.sha }}" || true 32 | - name: Push changes 33 | uses: ad-m/github-push-action@master 34 | with: 35 | branch: gh-pages 36 | directory: __gh-pages/ 37 | github_token: ${{ secrets.GITHUB_TOKEN }} 38 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "setuptools>=61.0", 4 | "numpy>=1.23.5" 5 | ] 6 | build-backend = "setuptools.build_meta" 7 | 8 | [project] 9 | name = "qubolite" 10 | version = "0.8.5" 11 | authors = [ 12 | { name = "Sascha Mücke", email="sascha.muecke@tu-dortmund.de" }, 13 | { name = "Thore Gerlach", email="thore.gerlach@iais.fraunhofer.de" }, 14 | { name = "Nico Piatkowski", email="nico.piatkowski@iais.fraunhofer.de" } 15 | ] 16 | description = "Toolbox for quadratic binary optimization" 17 | readme = "README.md" 18 | #repository = "https://github.com/smuecke/qubolite" 19 | #documentation = "https://smuecke.github.io/qubolite/api.html" 20 | keywords = ["qubo", "optimization", "quantum", "annealing"] 21 | requires-python = ">=3.8" 22 | classifiers = [ 23 | "Programming Language :: Python :: 3", 24 | "Topic :: Scientific/Engineering :: Mathematics", 25 | "Topic :: Scientific/Engineering :: Physics" 26 | ] 27 | dependencies = [ 28 | "numpy>=1.23.5", 29 | "networkx>=3.1" 30 | ] 31 | 32 | [project.urls] 33 | "Homepage" = "https://github.com/smuecke/qubolite" 34 | "Documentation" = "https://smuecke.github.io/qubolite" 35 | 36 | [project.optional-dependencies] 37 | roof_dual = [ 38 | "igraph>=0.10.4" 39 | ] 40 | kendall_tau = [ 41 | "scipy>=1.10.1" 42 | ] 43 | embedding = [ 44 | "scikit-learn>=1.2.2" 45 | ] 46 | preprocessing = [ 47 | "igraph>=0.10.4", 48 | "portion>=2.4.0" 49 | ] 50 | all = [ 51 | "igraph>=0.10.4", 52 | "scikit-learn>=1.2.2", 53 | "portion>=2.4.0", 54 | "scipy>=1.10.1" 55 | ] 56 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # For the full list of built-in configuration values, see the documentation: 4 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 5 | 6 | import os 7 | import sys 8 | sys.path.insert(0, os.path.abspath('..')) 9 | sys.path.insert(0, os.path.abspath('../..')) 10 | 11 | # -- Project information ----------------------------------------------------- 12 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information 13 | 14 | project = 'qubolite' 15 | copyright = '2023, Sascha Mücke, Thore Gerlach' 16 | author = 'Sascha Mücke, Thore Gerlach' 17 | release = '0.8' 18 | 19 | html_logo = '_static/logo.svg' 20 | 21 | # -- General configuration --------------------------------------------------- 22 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration 23 | 24 | extensions = [ 25 | 'sphinx.ext.autodoc', 26 | 'sphinx.ext.todo', 27 | 'sphinx.ext.mathjax', 28 | 'sphinx.ext.viewcode', 29 | 'sphinx.ext.napoleon', 30 | 'myst_parser' 31 | ] 32 | 33 | templates_path = ['_templates'] 34 | exclude_patterns = [ 35 | '_build', 36 | 'Thumbs.db', 37 | '.DS_Store' 38 | ] 39 | 40 | add_module_names = False 41 | autodoc_mock_imports = [ 42 | 'numpy', 43 | '_c_utils', 44 | 'sklearn', 45 | 'portion', 46 | 'igraph', 47 | 'networkx' 48 | ] 49 | 50 | # -- Options for HTML output ------------------------------------------------- 51 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output 52 | 53 | html_theme = 'sphinx_book_theme' 54 | html_static_path = ['_static'] 55 | 56 | # -- Options for todo extension ---------------------------------------------- 57 | # https://www.sphinx-doc.org/en/master/usage/extensions/todo.html#configuration 58 | 59 | todo_include_todos = True 60 | -------------------------------------------------------------------------------- /docs/source/_static/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 16 | 36 | 38 | 43 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /docs/source/api.md: -------------------------------------------------------------------------------- 1 | # API Documentation 2 | 3 | This page documents the classes and methods contained in the `qubolite` package. 4 | 5 | 6 | ## QUBO 7 | 8 | The base class for QUBO instances is `qubo`. 9 | 10 | ```{eval-rst} 11 | .. autoclass:: qubolite.qubo 12 | :members: 13 | :inherited-members: 14 | :undoc-members: 15 | :show-inheritance: 16 | ``` 17 | 18 | 19 | ## Bit Vectors 20 | 21 | The submodule `bitvec` contains useful methods for working with bit vectors, which in `qubolite` are just NumPy arrays containing the values `0.0` and `1.0`. 22 | 23 | ```{eval-rst} 24 | .. automodule:: qubolite.bitvec 25 | :members: 26 | :undoc-members: 27 | :show-inheritance: 28 | ``` 29 | 30 | 31 | ## Partial Assignment 32 | 33 | The submodule `assignment` implements a powerful tool that allows for partial 34 | assignments of bit vectors and, consequently, QUBO instances. This means that, 35 | given a bit vector of length `n`, a partial assignment fixes a subset of bits 36 | to either constant `0` or `1`, or to the (inverse) value of other variables. 37 | 38 | ```{eval-rst} 39 | .. automodule:: qubolite.assignment 40 | :members: 41 | :undoc-members: 42 | :show-inheritance: 43 | ``` 44 | 45 | 46 | ## Preprocessing 47 | 48 | The submodule `preprocessing` contains pre-processing methods for QUBO. 49 | 50 | ```{eval-rst} 51 | .. automodule:: qubolite.preprocessing 52 | :members: 53 | :undoc-members: 54 | :show-inheritance: 55 | ``` 56 | 57 | 58 | 59 | ## Solving 60 | 61 | `qubolite` provides the following methods for solving (exactly or approximately) QUBO instances to obtain their minimizing vector and minimal energy. 62 | 63 | ```{eval-rst} 64 | .. automodule:: qubolite.solving 65 | :members: 66 | :undoc-members: 67 | :show-inheritance: 68 | ``` 69 | 70 | 71 | ## Embedding 72 | 73 | `qubolite` can create QUBO instances out of other optimization problems by embedding. 74 | Note that for full functionality, you need the `scikit-learn` package. 75 | The following embeddings are available. 76 | 77 | ```{eval-rst} 78 | .. automodule:: qubolite.embedding 79 | :members: 80 | :undoc-members: 81 | :show-inheritance: 82 | ``` 83 | 84 | 85 | ## Bounds 86 | 87 | Solving QUBO problems is NP-hard. For certain applications it is helpful to have upper and lower bounds of the minimal energy, for which the submodule `bounds` provides some methods. 88 | Functions that return lower bounds are prefixed with `lb_`, and those that return upper bounds with `ub_`. 89 | 90 | ```{eval-rst} 91 | .. automodule:: qubolite.bounds 92 | :members: 93 | :undoc-members: 94 | :show-inheritance: 95 | ``` 96 | 97 | 98 | -------------------------------------------------------------------------------- /qubolite/_misc.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | from functools import wraps 3 | from hashlib import md5 4 | from importlib import import_module 5 | from sys import stderr 6 | 7 | import numpy as np 8 | 9 | 10 | # make warning message more minialistic 11 | def _custom_showwarning(message, *args, file=None, **kwargs): 12 | (file or stderr).write(f'Warning: {str(message)}\n') 13 | warnings.showwarning = _custom_showwarning 14 | 15 | 16 | def warn(*args, **kwargs): 17 | warnings.warn(*args, **kwargs) 18 | 19 | 20 | def deprecated(func): 21 | """This is a decorator which can be used to mark functions 22 | as deprecated. It will result in a warning being emitted 23 | when the function is used.""" 24 | @wraps(func) 25 | def new_func(*args, **kwargs): 26 | warnings.warn(f'{func.__name__} is deprecated', 27 | category=DeprecationWarning, 28 | stacklevel=2) 29 | return func(*args, **kwargs) 30 | return new_func 31 | 32 | 33 | def is_symmetrical(arr, rtol=1e-05, atol=1e-08): 34 | return np.allclose(arr, arr.T, rtol=rtol, atol=atol) 35 | 36 | 37 | def make_upper_triangle(arr): 38 | return np.triu(arr) + np.tril(arr, -1).T 39 | 40 | 41 | def is_triu(arr): 42 | return np.all(np.isclose(arr, np.triu(arr))) 43 | 44 | 45 | def min_max(it): 46 | min_ = float('inf') 47 | max_ = float('-inf') 48 | for x in it: 49 | if x < min_: min_ = x 50 | if x > max_: max_ = x 51 | return min_, max_ 52 | 53 | 54 | def to_shape(size_or_shape): 55 | if isinstance(size_or_shape, int): 56 | if size_or_shape == 1: 57 | return () 58 | return (size_or_shape,) 59 | return (*size_or_shape,) 60 | 61 | 62 | def warn_size(n: int, limit: int=30): 63 | if n > limit: 64 | warn(f'This operation may take a very long time for n>{limit}.') 65 | 66 | 67 | def get_random_state(state=None): 68 | if state is None: 69 | return np.random.default_rng() 70 | if isinstance(state, np.random._generator.Generator): 71 | return state 72 | if isinstance(state, np.random.RandomState): 73 | # for compatibility 74 | seed = state.randint(1<<31) 75 | return np.random.default_rng(seed) 76 | try: 77 | seed = int(state) 78 | except ValueError: 79 | # use hash digest when seed is a (non-numerical) string 80 | seed = int(md5(state.encode('utf-8')).hexdigest(), 16) & 0xffffffff 81 | return np.random.default_rng(seed) 82 | 83 | 84 | def set_suffix(filename, suffix): 85 | s = suffix.strip(' .') 86 | if filename.lower().endswith('.'+s.lower()): 87 | return filename 88 | else: 89 | return f'{filename}.{s}' 90 | 91 | 92 | def try_import(*libs): 93 | libdict = dict() 94 | for lib in libs: 95 | try: 96 | module = import_module(lib) 97 | except ModuleNotFoundError: 98 | continue 99 | libdict[lib] = module 100 | 101 | 102 | class __mock_cls: 103 | def __getattribute__(self, attrname): 104 | return lambda *args, **kwargs: None 105 | 106 | mock = __mock_cls() -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode/ 2 | test-env/ 3 | # Created by https://www.toptal.com/developers/gitignore/api/python 4 | # Edit at https://www.toptal.com/developers/gitignore?templates=python 5 | 6 | ### Python ### 7 | # Byte-compiled / optimized / DLL files 8 | __pycache__/ 9 | *.py[cod] 10 | *$py.class 11 | 12 | # C extensions 13 | *.so 14 | 15 | # Distribution / packaging 16 | .Python 17 | build/ 18 | develop-eggs/ 19 | dist/ 20 | downloads/ 21 | eggs/ 22 | .eggs/ 23 | lib/ 24 | lib64/ 25 | parts/ 26 | sdist/ 27 | var/ 28 | wheels/ 29 | share/python-wheels/ 30 | *.egg-info/ 31 | .installed.cfg 32 | *.egg 33 | MANIFEST 34 | 35 | # PyInstaller 36 | # Usually these files are written by a python script from a template 37 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 38 | *.manifest 39 | *.spec 40 | 41 | # Installer logs 42 | pip-log.txt 43 | pip-delete-this-directory.txt 44 | 45 | # Unit test / coverage reports 46 | htmlcov/ 47 | .tox/ 48 | .nox/ 49 | .coverage 50 | .coverage.* 51 | .cache 52 | nosetests.xml 53 | coverage.xml 54 | *.cover 55 | *.py,cover 56 | .hypothesis/ 57 | .pytest_cache/ 58 | cover/ 59 | 60 | # Translations 61 | *.mo 62 | *.pot 63 | 64 | # Django stuff: 65 | *.log 66 | local_settings.py 67 | db.sqlite3 68 | db.sqlite3-journal 69 | 70 | # Flask stuff: 71 | instance/ 72 | .webassets-cache 73 | 74 | # Scrapy stuff: 75 | .scrapy 76 | 77 | # Sphinx documentation 78 | docs/_build/ 79 | 80 | # PyBuilder 81 | .pybuilder/ 82 | target/ 83 | 84 | # Jupyter Notebook 85 | .ipynb_checkpoints 86 | 87 | # IPython 88 | profile_default/ 89 | ipython_config.py 90 | 91 | # pyenv 92 | # For a library or package, you might want to ignore these files since the code is 93 | # intended to run in multiple environments; otherwise, check them in: 94 | # .python-version 95 | 96 | # pipenv 97 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 98 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 99 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 100 | # install all needed dependencies. 101 | #Pipfile.lock 102 | 103 | # poetry 104 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 105 | # This is especially recommended for binary packages to ensure reproducibility, and is more 106 | # commonly ignored for libraries. 107 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 108 | #poetry.lock 109 | 110 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 111 | __pypackages__/ 112 | 113 | # Celery stuff 114 | celerybeat-schedule 115 | celerybeat.pid 116 | 117 | # SageMath parsed files 118 | *.sage.py 119 | 120 | # Environments 121 | .env 122 | .venv 123 | env/ 124 | venv/ 125 | ENV/ 126 | env.bak/ 127 | venv.bak/ 128 | 129 | # Spyder project settings 130 | .spyderproject 131 | .spyproject 132 | 133 | # Rope project settings 134 | .ropeproject 135 | 136 | # mkdocs documentation 137 | /site 138 | 139 | # mypy 140 | .mypy_cache/ 141 | .dmypy.json 142 | dmypy.json 143 | 144 | # Pyre type checker 145 | .pyre/ 146 | 147 | # pytype static type analyzer 148 | .pytype/ 149 | 150 | # Cython debug symbols 151 | cython_debug/ 152 | 153 | # PyCharm 154 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 155 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 156 | # and can be added to the global gitignore or merged into this file. For a more nuclear 157 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 158 | #.idea/ 159 | 160 | # End of https://www.toptal.com/developers/gitignore/api/python 161 | -------------------------------------------------------------------------------- /docs/source/index.md: -------------------------------------------------------------------------------- 1 | ```{toctree} 2 | :hidden: 3 | 4 | self 5 | api 6 | Source Code 7 | ``` 8 | 9 | # Quickstart 10 | 11 | This package provides tools for working with *Quadratic Unconstrained Binary Optimization* ([QUBO](https://en.wikipedia.org/wiki/Quadratic_unconstrained_binary_optimization)) problems. These problems are central for Quantum Annealing and have numerous applications in Machine Learning, resource allocation, data analysis and many more. 12 | 13 | Given a real-valued parameter matrix **Q**, the *energy* of a binary vector **x** is defined as 14 | 15 | ```{math} 16 | f_{\boldsymbol Q}(\boldsymbol x)=\sum_{1\leq i\leq j\leq n}Q_{ij}x_ix_j\;. 17 | ``` 18 | 19 | The task is to find a binary vector that minimizes this energy function. 20 | 21 | The philosophy of this package is to be as light-weight as possible, therefore the core class `qubo` is just a shallow wrapper around a NumPy array containing the QUBO parameters, with many useful methods. Additionally, the package contains methods for **solving** QUBOs, **sampling** from their Gibbs distribution, **bounding** their minimum energy, and **embedding** other problems into QUBO. 22 | 23 | 24 | ## Installation 25 | 26 | This package is available on PyPi and can be installed via 27 | 28 | ``` 29 | pip install qubolite 30 | ``` 31 | 32 | Note that this package contains code in C, which is why you need a working C compiler (GCC on Linux, Visual C/C++ on Windows). 33 | 34 | 35 | ## Documentation 36 | 37 | The full **API documentation** can be found by clicking the link on the left, or [here](https://smuecke.github.io/qubolite/api.html). 38 | 39 | The **source code** is publicly available on [GitHub](https://github.com/smuecke/qubolite). 40 | 41 | 42 | ## Usage Examples 43 | 44 | The core class is `qubo`, which receives a `numpy.ndarray` of size `(n, n)`. 45 | Alternatively, a random instance can be created using `qubo.random()`. 46 | 47 | ``` 48 | >>> import numpy as np 49 | >>> from qubolite import qubo 50 | >>> arr = np.triu(np.random.random((8, 8))) 51 | >>> Q = qubo(arr) 52 | >>> Q2 = qubo.random(12, distr='uniform') 53 | ``` 54 | 55 | By default, `qubo()` takes an upper triangle matrix. 56 | A non-triangular matrix is converted to an upper triangle matrix by adding the lower to the upper triangle. 57 | QUBO instances can also be created from dictionaries through `qubo.from_dict()`. 58 | 59 | To get the QUBO function value, instances can be called directly with a bit vector. 60 | The bit vector must be a `numpy.ndarray` of size `(n,)` or `(m, n)`. 61 | 62 | ``` 63 | >>> x = np.random.random(8) < 0.5 64 | >>> Q(x) 65 | 7.488225478498116 66 | >>> xs = np.random.random((5,8)) < 0.5 # evaluate 5 bit vectors at once 67 | >>> Q(xs) 68 | array([5.81642745, 4.41380893, 11.3391062, 4.34253921, 6.07799747]) 69 | ``` 70 | 71 | 72 | ### Solving 73 | 74 | The submodule `solving` contains several methods to obtain the minimizing bit vector or energy value of a given QUBO instance, both exact and approximative. 75 | 76 | ``` 77 | >>> from qubolite.solving import brute_force 78 | >>> x_min, value = brute_force(Q, return_value=True) 79 | >>> x_min 80 | array([1., 1., 1., 0., 1., 0., 0., 0.]) 81 | >>> value 82 | -3.394893116198653 83 | ``` 84 | 85 | The method `brute_force` is implemented efficiently in C and parallelized with OpenMP. 86 | Still, for instances with more than 30 variables take a long time to solve this way. 87 | Other methods included in this package are Simulated Annealing and some heuristic search methods (local search, random search). 88 | 89 | 90 | ### Embedding 91 | 92 | `qubolite` provides QUBO embeddings for common optimization problems. 93 | For example, the following code shows how to solve a binary clustering problem: 94 | 95 | ``` 96 | >>> from qubolite.embedding import Kernel2MeansClustering 97 | >>> X = np.random.normal(size=(30, 2)) # sample 2D points 98 | >>> X[15:,0] += 5 # move half of the points apart to create two clusters 99 | >>> problem = Kernel2MeansClustering(X, kernel='rbf') 100 | >>> brute_force(problem.qubo) 101 | (array([1., 1., 1., 1., 1., 0., 1., 1., 1., 1., 1., 1., 1., 1., 1., 0., 0., 102 | 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.]), -44.55740335019274) 103 | ``` 104 | -------------------------------------------------------------------------------- /qubolite/bounds.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | from ._misc import get_random_state 4 | from .qubo import qubo 5 | from .solving import random_search, local_descent 6 | 7 | 8 | def lb_roof_dual(Q: qubo): 9 | """Compute the Roof Dual bound, as described in `[1] `__. 10 | To this end, the QUBO instance is converted to a 11 | corresponding flow network, whose maximum flow value 12 | yields the roof dual lower bound. 13 | 14 | Args: 15 | Q (qubo): QUBO instance. 16 | 17 | Raises: 18 | ImportError: Raised if the ``igraph`` package is missing. 19 | This package is required for this method. 20 | 21 | Returns: 22 | float: A lower bound on the minimal energy value. 23 | """ 24 | try: 25 | from igraph import Graph 26 | except ImportError as e: 27 | raise ImportError( 28 | "igraph needs to be installed prior to running qubolite.lb_roof_dual(). You can " 29 | "install igraph with:\n'pip install igraph'" 30 | ) from e 31 | 32 | def to_flow_graph(P): 33 | n = P.shape[1] 34 | G = Graph(directed=True) 35 | vertices = np.arange(n + 1) 36 | negated_vertices = np.arange(n + 1, 2 * n + 2) 37 | # all vertices for flow graph 38 | all_vertices = np.concatenate([vertices, negated_vertices]) 39 | G.add_vertices(all_vertices) 40 | # arrays of vertices containing node x0 41 | n0 = np.kron(vertices[1:][:, np.newaxis], np.ones(n, dtype=int)) 42 | np.fill_diagonal(n0, np.zeros(n)) 43 | nn0 = np.kron(negated_vertices[1:][:, np.newaxis], np.ones(n, dtype=int)) 44 | np.fill_diagonal(nn0, (n + 1) * np.ones(n)) 45 | # arrays of vertices not containing node x0 46 | n1 = np.kron(np.ones(n, dtype=int)[:, np.newaxis], vertices[1:]) 47 | nn1 = np.kron(np.ones(n, dtype=int)[:, np.newaxis], negated_vertices[1:]) 48 | 49 | n0_nn1 = np.stack((n0, nn1), axis=-1) # edges from ni to !nj 50 | n1_nn0 = np.stack((n1, nn0), axis=-1) # edges from nj to !ni 51 | n0_n1 = np.stack((n0, n1), axis=-1) # edges from ni to nj 52 | nn1_nn0 = np.stack((nn1, nn0), axis=-1) # edges from !nj to !ni 53 | pos_indices = np.invert(np.isclose(P[0], 0)) 54 | neg_indices = np.invert(np.isclose(P[1], 0)) 55 | # set capacities to half of posiform parameters 56 | capacities = 0.5 * np.concatenate([P[0][pos_indices], P[0][pos_indices], 57 | P[1][neg_indices], P[1][neg_indices]]) 58 | edges = np.concatenate([n0_nn1[pos_indices], n1_nn0[pos_indices], 59 | n0_n1[neg_indices], nn1_nn0[neg_indices]]) 60 | G.add_edges(edges) 61 | return G, capacities 62 | 63 | P, const = Q.to_posiform() 64 | G, capacities = to_flow_graph(P) 65 | v = G.maxflow_value(0, Q.n + 1, capacity=list(capacities)) 66 | return const + v 67 | 68 | 69 | def lb_negative_parameters(Q: qubo): 70 | """Compute a simple lower bound on the minimal energy 71 | by summing up all negative parameters. As all QUBO 72 | energy values are sums of parameter subsets, the 73 | smallest subset sum is a lower bound for the minimal energy. 74 | This bound is very fast, but very weak, especially for large 75 | QUBO sizes. 76 | 77 | Args: 78 | Q (qubo): QUBO instance. 79 | 80 | Returns: 81 | float: A lower bound on the minimal energy value. 82 | """ 83 | return np.minimum(Q.m, 0).sum() 84 | 85 | 86 | # upper bounds ------------------------ 87 | 88 | def ub_sample(Q: qubo, samples=10_000, random_state=None): 89 | """Compute an upper bound on the minimal energy by sampling 90 | and taking the minimal encountered value. 91 | 92 | Args: 93 | Q (qubo): QUBO instance. 94 | samples (int, optional): Number of samples. Defaults to 10_000. 95 | random_state (optional): A numerical or lexical seed, or a NumPy random generator. Defaults to None. 96 | 97 | Returns: 98 | float: An upper bound on the minimal energy value. 99 | """ 100 | _, v = random_search(Q, steps=samples, random_state=random_state) 101 | return v 102 | 103 | 104 | def ub_local_descent(Q: qubo, restarts=10, random_state=None): 105 | """Compute an upper bound on the minimal energy by repeatedly 106 | performing a local optimization heuristic and returning the 107 | lowest energy value encountered. 108 | 109 | Args: 110 | Q (qubo): QUBO instance. 111 | restarts (int, optional): Number of local searches with 112 | random initial bit vectors. Defaults to 10. 113 | random_state (optional): A numerical or lexical seed, or a NumPy random generator. Defaults to None. 114 | 115 | Returns: 116 | float: An upper bound on the minimal energy value. 117 | """ 118 | npr = get_random_state(random_state) 119 | min_val = float('inf') 120 | for _ in range(restarts): 121 | _, v = local_descent(Q, random_state=npr) 122 | min_val = min(min_val, v) 123 | return min_val 124 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # qubolite 2 | 3 | A light-weight toolbox for working with QUBO instances in NumPy. 4 | 5 | 6 | 7 | 8 | 9 | ## Installation 10 | 11 | Ensure you have the Python development package installed for your Python version, e.g., for Python 3.11 running on Ubuntu, 12 | ``` 13 | apt install python3.11-dev 14 | ``` 15 | 16 | Then simply install `qubolite` itself from the PyPI repository through 17 | ``` 18 | pip install qubolite 19 | ``` 20 | 21 | This package was created using Python 3.10, but runs with Python >= 3.8. 22 | 23 | ## Optional Dependencies 24 | 25 | If you're planning to use the roof dual function as lower bound you will need to install optional 26 | dependencies. The igraph based roof dual lower bound function can be used by calling 27 | `qubolite.bounds.lb_roof_dual()`. It requires that the [igraph](https://igraph.org/) library is 28 | installed. This can be done with `pip install igraph` or by installing qubolite with 29 | `pip install qubolite[roof_dual]`. 30 | 31 | Using the function `qubolite.ordering_distance()` requires the Kendall-τ measure from the 32 | [scipy](https://scipy.org/) library which can be installed by `pip install scipy` or by installing 33 | qubolite with `pip install qubolite[kendall_tau]`. 34 | 35 | For exemplary QUBO embeddings (e.g. clustering or subset sum), the 36 | [scikit-learn](https://scikit-learn.org/) library is required. It can be installed by either using 37 | `pip install scikit-learn` or installing qubolite with `pip install qubolite[embeddings]`. 38 | 39 | If you would like to install all optional dependencies you can use `pip install qubolite[all]` for 40 | achieving this. 41 | 42 | ## Usage Examples 43 | 44 | By design, `qubolite` is a shallow wrapper around `numpy` arrays, which represent QUBO parameters. 45 | The core class is `qubo`, which receives a `numpy.ndarray` of size `(n, n)`. 46 | Alternatively, a random instance can be created using `qubo.random()`. 47 | 48 | ``` 49 | >>> import numpy as np 50 | >>> from qubolite import qubo 51 | >>> arr = np.triu(np.random.random((8, 8))) 52 | >>> Q = qubo(arr) 53 | >>> Q2 = qubo.random(12, distr='uniform') 54 | ``` 55 | 56 | By default, `qubo()` takes an upper triangle matrix. 57 | A non-triangular matrix is converted to an upper triangle matrix by adding the lower to the upper triangle. 58 | 59 | To get the QUBO function value, instances can be called directly with a bit vector. 60 | The bit vector must be a `numpy.ndarray` of size `(n,)` or `(m, n)`. 61 | 62 | ``` 63 | >>> x = np.random.random(8) < 0.5 64 | >>> Q(x) 65 | 7.488225478498116 66 | >>> xs = np.random.random((5,8)) < 0.5 67 | >>> Q(xs) 68 | array([5.81642745, 4.41380893, 11.3391062, 4.34253921, 6.07799747]) 69 | ``` 70 | 71 | ### Solving 72 | 73 | The submodule `solving` contains several methods to obtain the minimizing bit vector or energy value of a given QUBO instance, both exact and approximative. 74 | 75 | ``` 76 | >>> from qubolite.solving import brute_force 77 | >>> x_min, value = brute_force(Q, return_value=True) 78 | >>> x_min 79 | array([1., 1., 1., 0., 1., 0., 0., 0.]) 80 | >>> value 81 | -3.394893116198653 82 | ``` 83 | 84 | The method `brute_force` is implemented efficiently in C and parallelized with OpenMP. 85 | Still, for instances with more than 30 variables take a long time to solve this way. 86 | 87 | ## Documentation 88 | 89 | The complete API documentation can be found [here](https://smuecke.github.io/qubolite/api.html). 90 | 91 | ## Version Log 92 | 93 | * **0.2** Added problem embeddings (binary clustering, subset sum problem) 94 | * **0.3** Added `QUBOSample` class and sampling methods `full` and `gibbs` 95 | * **0.4** Renamed `QUBOSample` to `BinarySample`; added methods for saving and loading QUBO and Sample instances 96 | * **0.5** Moved `gibbs` to `mcmc` and implemented true Gibbs sampling as `gibbs`; added `numba` as dependency 97 | * **0.5.1** changed `keep_prob` to `keep_interval` in Gibbs sampling, making the algorithm's runtime deterministic; renamed `sample` to `random` in QUBO embedding classes, added MAX 2-SAT problem embedding 98 | * **0.6** Changed Python version to 3.8; removed `bitvec` dependency; added `scipy` dependency required for matrix operations in numba functions 99 | * **0.6.1** added scaling and rounding 100 | * **0.6.2** removed `seedpy` dependency 101 | * **0.6.3** renamed `shots` to `size` in `BinarySample`; cleaned up sampling, simplified type hints 102 | * **0.6.4** added probabilistic functions to `qubo` class 103 | * **0.6.5** complete empirical prob. vector can be returned from `BinarySample` 104 | * **0.6.6** fixed spectral gap implementation 105 | * **0.6.7** moved `brute_force` to new sub-module `solving`; added some approximate solving methods 106 | * **0.6.8** added `bitvec` sub-module; `dynamic_range` now uses bits by default, changed `bits=False` to `decibel=False`; removed scipy from requirements 107 | * **0.6.9** new, more memory-efficient save format 108 | * **0.6.10** fixed requirements in `setup.py`; fixed size estimation in `qubo.save()` 109 | * **0.7** Added more efficient brute-force implementation using C extension; added optional dependencies for calculating bounds and ordering distance 110 | * **0.8** New embeddings, new solving methods; switched to NumPy random generators from `RandomState`; added parameter compression for dynamic range reduction; Added documentation 111 | * **0.8.1** some fixes to documentation 112 | * **0.8.2** implemented `qubo.dx2()`; added several new solving heuristics 113 | * **0.8.3** added submodule `preprocessing` and moved DR reduction there; added `partial_assignment` class as replacement of `qubo.clamp()`, which is now deprecated 114 | * **0.8.4** added fast Gibbs sampling and QUBO parameter training 115 | -------------------------------------------------------------------------------- /qubolite/bitvec.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | import numpy as np 4 | from numpy.typing import ArrayLike 5 | 6 | from ._misc import get_random_state 7 | 8 | 9 | def all_bitvectors(n: int): 10 | """Generate all bit vectors of size ``n`` in lexicographical order, starting from all zeros. 11 | The least significant bit is at index 0. Note that always the same array object is yielded, 12 | so to persist the bit vectors you need to make copies. 13 | 14 | Args: 15 | n (int): Number of bits. 16 | 17 | Yields: 18 | numpy.ndarray: Array of shape ``(n,)`` containing a bit vector. 19 | 20 | Exapmles: 21 | This method can be used to obtain all possible energy values for a given QUBO instance: 22 | 23 | >>> Q = qubo.random(3) 24 | >>> for x in all_bitvectors(3): 25 | ... print(to_string(x), '->', Q(x)) 26 | ... 27 | 000 -> 0.0 28 | 100 -> 0.6294629779101759 29 | 010 -> 0.1566040993504083 30 | 110 -> 0.5098350500036248 31 | 001 -> 1.5430218546339793 32 | 101 -> 3.9359808951564057 33 | 011 -> 2.1052824965032304 34 | 111 -> 4.222009509768697 35 | """ 36 | x = np.zeros(n) 37 | b = 2**np.arange(n) 38 | for k in range(1< 0 40 | yield x 41 | 42 | 43 | def all_bitvectors_array(n: int): 44 | """Create an array containing all bit vectors of size ``n`` in 45 | lexicographical order. 46 | 47 | Args: 48 | n (int): Size of bit vectors. 49 | 50 | Returns: 51 | numpy.ndarray: Array of shape ``(2**n, n)`` 52 | """ 53 | return np.arange(1< 0 54 | 55 | 56 | def random(n: int, size=None, random_state=None): 57 | """Create an array containing random bit vectors. 58 | 59 | Args: 60 | n (int): Number of bits per bit vector. 61 | size (int | tuple, optional): Number of bit vectors to sample, or tuple 62 | representing a shape. Defaults to None, which returns a single bit 63 | vector (shape ``(n,)``). 64 | random_state (optional): A numerical or lexical seed, or a NumPy random 65 | generator. Defaults to None. 66 | 67 | Returns: 68 | numpy.ndarray: Random bit vector(s). 69 | """ 70 | size = () if size is None else size 71 | try: 72 | shape = (*size, n) 73 | except TypeError: 74 | # `size` must be an integer 75 | shape = (size, n) 76 | rng = get_random_state(random_state) 77 | return (rng.random(shape) < 0.5).astype(np.float64) 78 | 79 | 80 | def from_string(string: str): 81 | """Convert a string consisting of ``0`` and ``1`` to a bit vector. 82 | 83 | Args: 84 | string (str): Binary string. 85 | 86 | Returns: 87 | numpy.ndarray: Bit vector. 88 | 89 | Examples: 90 | This method is useful to quickly convert binary strings 91 | to numpy array: 92 | 93 | >>> from_string('100101') 94 | array([1., 0., 0., 1., 0., 1.]) 95 | """ 96 | return np.fromiter(string, dtype=np.float64) 97 | 98 | 99 | def to_string(bitvec: ArrayLike): 100 | """Convert a bit vector to a string. 101 | If an array of bit vectors (shape ``(m, n)``) is passed, a numpy.ndarray 102 | containing string objects is returned, one for each row. 103 | 104 | Args: 105 | bitvec (ArrayLike): Bit vector ``(n,)`` or array of bit vectors ``(m, n)`` 106 | 107 | Returns: 108 | string: Binary string 109 | """ 110 | if bitvec.ndim <= 1: 111 | return ''.join(str(int(x)) for x in bitvec) 112 | else: 113 | return np.apply_along_axis(to_string, -1, bitvec) 114 | 115 | 116 | def from_dict(d: dict, n=None): 117 | """Convert a dictionary to a bit vector. 118 | The dictionary should map indices (int) to binary values (0 or 1). 119 | If ``n`` is specified, the vector is padded with zeros to length ``n``. 120 | 121 | Args: 122 | d (dict): Dictionary containing index to bit assignments. 123 | n (int, optional): Length of the bit vector. Defaults to None, which uses the highest index in ``d`` as length. 124 | 125 | Returns: 126 | numpy.ndarray: Bit vector. 127 | """ 128 | n = max(d.keys())+1 if n is None else n 129 | x = np.zeros(n) 130 | for i, b in d.items(): 131 | x[i] = b 132 | return x 133 | 134 | 135 | def to_dict(bitvec: ArrayLike): 136 | """Convert a bit vector to a dictionary mapping index (int) 137 | to bit value (0 or 1). 138 | 139 | Args: 140 | bitvec (ArrayLike): Bit vector of shape ``(n,)``. 141 | 142 | Returns: 143 | dict: Dictionary representation of the bit vector. 144 | 145 | Examples: 146 | This function is useful especially when working with 147 | D-Wave's Python packages, as they often use this format. 148 | 149 | >>> to_dict(from_string('10101100')) 150 | {0: 1, 1: 0, 2: 1, 3: 0, 4: 1, 5: 1, 6: 0, 7: 0} 151 | """ 152 | return { i: int(b) for i, b in enumerate(bitvec) } 153 | 154 | 155 | # Manipulate Bit Vectors 156 | # ====================== 157 | 158 | def flip_index(x, index, in_place=False): 159 | """Flips the values of a given bit vector at the specified index or indices. 160 | 161 | Args: 162 | x (numpy.ndarray): Bit vector(s). 163 | index (int | list | numpy.ndarray): Index or list of indices where to 164 | flip the binary values. 165 | in_place (bool, optional): If ``True``, modify the bit vector in place. 166 | The return value will be a reference to the input array. Defaults to 167 | False. 168 | 169 | Returns: 170 | numpy.ndarray: Bit vector(s) with the indices flipped at the specified 171 | positions. If ``in_place=True``, this will be a reference to the 172 | input array, otherwise a copy. 173 | 174 | Examples: 175 | The following inverts the first and last bits of all given bitvectors: 176 | 177 | >>> x = from_expression('**10') 178 | >>> x 179 | array([[0., 0., 1., 0.], 180 | [1., 0., 1., 0.], 181 | [0., 1., 1., 0.], 182 | [1., 1., 1., 0.]]) 183 | >>> flip_index(x, [0, -1]) 184 | array([[1., 0., 1., 1.], 185 | [0., 0., 1., 1.], 186 | [1., 1., 1., 1.], 187 | [0., 1., 1., 1.]]) 188 | """ 189 | x_ = x if in_place else x.copy() 190 | x_[..., index] = 1-x_[..., index] 191 | return x_ -------------------------------------------------------------------------------- /qubolite/_heuristics.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | 4 | class Greedy: 5 | @staticmethod 6 | def compute_change(matrix_order, i, j, increase=True): 7 | sorted_index = matrix_order.get_sorted_index(i, j) 8 | S = matrix_order.unique_elements 9 | sorted = matrix_order.unique 10 | second_min_distance = matrix_order.second_min_distance 11 | if sorted_index == 0: 12 | if matrix_order.min_index_lower == sorted_index and not np.isclose(second_min_distance, 13 | matrix_order.min_distance): 14 | change = sorted[S - 1] - 2 * sorted[0] + sorted[1] + matrix_order.extra_summand 15 | else: 16 | change = sorted[S - 1] - 2 * sorted[0] + sorted[1] 17 | elif sorted_index == S - 1: 18 | if matrix_order.min_index_upper == sorted_index and not np.isclose(second_min_distance, 19 | matrix_order.min_distance): 20 | change = sorted[0] - 2 * sorted[S - 1] + sorted[S - 2] - matrix_order.extra_summand 21 | else: 22 | change = sorted[0] - 2 * sorted[S - 1] + sorted[S - 2] 23 | else: 24 | if increase: 25 | if matrix_order.min_index_lower == sorted_index and not np.isclose(second_min_distance, 26 | matrix_order.min_distance): 27 | change = sorted[S - 1] - sorted[sorted_index] + matrix_order.extra_summand 28 | else: 29 | change = sorted[S - 1] - sorted[sorted_index] - matrix_order.min_distance 30 | else: 31 | if matrix_order.min_index_upper == sorted_index and not np.isclose(second_min_distance, 32 | matrix_order.min_distance): 33 | change = sorted[1] - sorted[sorted_index] - matrix_order.extra_summand 34 | else: 35 | change = sorted[1] - sorted[sorted_index] + matrix_order.min_distance 36 | return change 37 | 38 | @staticmethod 39 | def decide_increase(matrix_order, i, j): 40 | sorted_index = matrix_order.get_sorted_index(i, j) 41 | if matrix_order.unique[sorted_index] < 0: 42 | increase = True 43 | else: 44 | increase = False 45 | return increase 46 | 47 | @staticmethod 48 | def set_to_zero(): 49 | return False 50 | 51 | 52 | class GreedyZero: 53 | @staticmethod 54 | def compute_change(matrix_order, i, j, increase=True): 55 | return Greedy.compute_change(matrix_order, i, j, increase=increase) 56 | 57 | @staticmethod 58 | def decide_increase(matrix_order, i, j): 59 | return Greedy.decide_increase(matrix_order, i, j) 60 | 61 | @staticmethod 62 | def set_to_zero(): 63 | return True 64 | 65 | 66 | class MaintainOrder: 67 | @staticmethod 68 | def compute_change(matrix_order, i, j, increase=True): 69 | sorted_index = matrix_order.get_sorted_index(i, j) 70 | S = matrix_order.unique_elements 71 | sorted = matrix_order.unique 72 | if sorted_index == 0: 73 | if increase: 74 | change = sorted[1] - sorted[0] - matrix_order.min_distance 75 | else: 76 | change = - matrix_order.extra_summand 77 | elif sorted_index == S - 1: 78 | if increase: 79 | change = matrix_order.extra_summand 80 | else: 81 | change = sorted[S - 2] - sorted[S - 1] + matrix_order.min_distance 82 | else: 83 | current = sorted[sorted_index] 84 | if increase: 85 | lower, upper = matrix_order.obtain_increase_bounds(sorted_index) 86 | mid = (upper - lower) / 2.0 87 | min_Q = min(upper - current, current - lower) 88 | change = mid - min_Q 89 | else: 90 | lower, upper = matrix_order.obtain_decrease_bounds(sorted_index) 91 | mid = (lower - upper) / 2.0 92 | min_Q = min(upper - current, current - lower) 93 | change = mid + min_Q 94 | return change 95 | 96 | @staticmethod 97 | def decide_increase(matrix_order, i, j): 98 | sorted_index = matrix_order.get_sorted_index(i, j) 99 | sorted = matrix_order.unique 100 | if sorted_index == 0: 101 | second_min_distance = matrix_order.second_min_distance 102 | if matrix_order.min_index_lower == sorted_index and not np.isclose(second_min_distance, 103 | matrix_order.min_distance): 104 | increase = False 105 | else: 106 | increase = True 107 | elif sorted_index == sorted.shape[0] - 1: 108 | second_min_distance = matrix_order.second_min_distance 109 | if matrix_order.min_index_upper == sorted_index and not np.isclose(second_min_distance, 110 | matrix_order.min_distance): 111 | increase = True 112 | else: 113 | increase = False 114 | else: 115 | current = sorted[sorted_index] 116 | lower, _ = matrix_order.obtain_decrease_bounds(sorted_index) 117 | _, upper = matrix_order.obtain_increase_bounds(sorted_index) 118 | if current - lower < upper - current: 119 | increase = True 120 | else: 121 | increase = False 122 | return increase 123 | 124 | @staticmethod 125 | def set_to_zero(): 126 | return False 127 | 128 | 129 | HEURISTICS = { 130 | 'greedy0': GreedyZero, 131 | 'greedy': Greedy, 132 | 'order': MaintainOrder 133 | } 134 | 135 | 136 | class MatrixOrder: 137 | def __init__(self, matrix, precision=8): 138 | self.precision = precision 139 | self.matrix = matrix 140 | self.matrix = np.round(self.matrix, decimals=self.precision) 141 | self.sorted = np.sort(self.matrix, axis=None) 142 | self.unique, self.indices, self.sorted_indices, self.counts = np.unique(self.matrix, 143 | return_inverse=True, 144 | return_index=True, 145 | return_counts=True) 146 | self.distances = self.unique[1:] - self.unique[:-1] 147 | self.min_index_lower = np.argmin(self.distances) 148 | self.min_index_upper = self.min_index_lower + 1 149 | self.min_distance = self.distances[self.min_index_lower] 150 | self.exclusive_min_distance = np.min(self.distances[self.distances > self.min_distance]) 151 | self.second_min_distance = np.min(np.r_[self.distances[:self.min_index_lower], 152 | self.distances[self.min_index_lower + 1:]]) 153 | self.extra_summand = self.exclusive_min_distance - self.min_distance 154 | self.dynamic_range = np.log2((self.unique[-1] - self.unique[0]) / self.min_distance) 155 | self.unique_elements = len(self.unique) 156 | 157 | def update_entry(self, i, j, change, return_matrix=False): 158 | if not isinstance(change, str): 159 | new_value = self.matrix[i, j] + change 160 | else: 161 | new_value = self.unique[int(change)] 162 | new_value = np.round(new_value, decimals=self.precision) 163 | if return_matrix: 164 | matrix = self.matrix.copy() 165 | matrix[i, j] = new_value 166 | return matrix 167 | self.matrix[i, j] = new_value 168 | self.sorted = np.sort(self.matrix, axis=None) 169 | self.unique, self.indices, self.sorted_indices, self.counts = np.unique(self.matrix, 170 | return_inverse=True, 171 | return_index=True, 172 | return_counts=True) 173 | self.distances = self.unique[1:] - self.unique[:-1] 174 | self.min_index_lower = np.argmin(self.distances) 175 | self.min_index_upper = self.min_index_lower + 1 176 | self.min_distance = self.distances[self.min_index_lower] 177 | if (np.invert(self.distances > self.min_distance)).all(): 178 | return True 179 | 180 | self.exclusive_min_distance = np.min(self.distances[self.distances > self.min_distance]) 181 | self.second_min_distance = np.min(np.r_[self.distances[:self.min_index_lower], 182 | self.distances[self.min_index_lower + 1:]]) 183 | self.extra_summand = self.exclusive_min_distance - self.min_distance 184 | self.dynamic_range = np.log2((self.unique[-1] - self.unique[0]) / self.min_distance) 185 | self.unique_elements = len(self.unique) 186 | return False 187 | 188 | def get_sorted_index(self, i, j): 189 | return self.sorted_indices[i * self.matrix.shape[0] + j] 190 | 191 | def obtain_increase_bounds(self, sorted_index): 192 | # sorted_index = self.get_sorted_index(i, j) 193 | upper_increase = self.unique[sorted_index + 1] 194 | if self.counts[sorted_index] > 1: 195 | lower_increase = self.unique[sorted_index] 196 | else: 197 | lower_increase = self.unique[sorted_index - 1] 198 | return lower_increase, upper_increase 199 | 200 | def obtain_decrease_bounds(self, sorted_index): 201 | # sorted_index = self.get_sorted_index(i, j) 202 | lower_decrease = self.unique[sorted_index - 1] 203 | if self.counts[sorted_index] > 1: 204 | upper_decrease = self.unique[sorted_index] 205 | else: 206 | upper_decrease = self.unique[sorted_index + 1] 207 | return lower_decrease, upper_decrease 208 | 209 | def dynamic_range_impact(self): 210 | elems = [] 211 | # add border elements for max D 212 | if self.counts[0] == 1: 213 | elems.append(self.indices[0]) 214 | if self.counts[-1] == 1: 215 | elems.append(self.indices[-1]) 216 | # add elements for min D 217 | dists = self.distances 218 | min_dist_indices = np.where(np.isclose(dists, self.min_distance))[0] 219 | for dist_index in min_dist_indices: 220 | if self.unique[dist_index] != 0: 221 | if self.counts[dist_index] == 1: 222 | if self.indices[dist_index] not in elems: 223 | elems.append(self.indices[dist_index]) 224 | if self.unique[dist_index + 1] != 0: 225 | if self.counts[dist_index + 1] == 1: 226 | if self.indices[dist_index + 1] not in elems: 227 | elems.append(self.indices[dist_index + 1]) 228 | return elems 229 | 230 | @staticmethod 231 | def to_matrix_indices(indices, n): 232 | return [(index // n, index % n) for index in indices] 233 | -------------------------------------------------------------------------------- /qubolite/embedding.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | 3 | import numpy as np 4 | from sklearn.metrics import pairwise_kernels, pairwise_distances 5 | from sklearn.preprocessing import KernelCenterer 6 | 7 | from ._misc import get_random_state 8 | from .qubo import qubo 9 | 10 | 11 | class qubo_embedding: 12 | def map_solution(self, x): 13 | return NotImplemented 14 | 15 | @property 16 | def qubo(self): 17 | return NotImplemented 18 | 19 | @property 20 | def data(self): 21 | return NotImplemented 22 | 23 | @classmethod 24 | def random(cls, n: int, random_state=None): 25 | return NotImplemented 26 | 27 | 28 | class Kernel2MeansClustering(qubo_embedding): 29 | """Binary clustering based on kernel matrices. 30 | 31 | Args: 32 | data (numpy.ndarray): Data array of shape ``(n, m)``. 33 | The QUBO instance will have size ``n`` (or ``n-1``, if ``unambiguous=True``). 34 | kernel (str, optional): Kernel function. Defaults to ``'linear'``. 35 | Can be any of `those `__. 36 | centered (bool, optional): If ``True``, center the Kernel matrix. Defaults to ``True``. 37 | unambiguous (bool, optional): If ``True``, assign the last data point to cluster 0 and exclude it 38 | from the optimization. Otherwise, the resulting QUBO instance would have two 39 | symmetrical optimal cluster assignments. Defaults to False. 40 | **kernel_params: Additional keyword arguments for the Kernel function, passed to ``sklearn.metrics.pairwise_kernels``. 41 | """ 42 | def __init__(self, data, kernel='linear', centered=True, unambiguous=False, **kernel_params): 43 | # for different kernels: https://scikit-learn.org/stable/modules/metrics.html 44 | self.__data = data 45 | self.__kernel = kernel 46 | self.__kernel_params = kernel_params 47 | self.__centered = centered 48 | self.__unambiguous = unambiguous 49 | self.__Q = self.__from_data() 50 | 51 | @property 52 | def qubo(self): 53 | return self.__Q 54 | 55 | @property 56 | def data(self): 57 | return dict(points=self.__data) 58 | 59 | def __from_data(self): 60 | # calculate kernel matrix 61 | K = pairwise_kernels(X=self.__data, metric=self.__kernel, **self.__kernel_params) 62 | # center kernel matrix 63 | if self.__centered: 64 | K = KernelCenterer().fit_transform(K) 65 | q = -K 66 | np.fill_diagonal(q, K.sum(1) - K.diagonal()) 67 | # fix z_n=0 for cluster assignment 68 | if self.__unambiguous: 69 | n = K.shape[0] 70 | q = q[:n-1, :n-1] 71 | return qubo(q) 72 | 73 | def map_solution(self, x): 74 | # return cluster assignments (-1, +1) 75 | return 2 * x - 1 76 | 77 | @classmethod 78 | def random(cls, n: int, dim=2, dist=2.0, random_state=None, kernel=None, centered=True, 79 | unambiguous=True, **kernel_params): 80 | if kernel is None: 81 | kernel = 'linear' 82 | kernel_params = {} 83 | npr = get_random_state(random_state) 84 | data = npr.normal(size=(n, dim)) 85 | mask = npr.permutation(n) < n // 2 86 | data[mask, :] += dist / np.sqrt(dim) 87 | data -= data.mean(0) 88 | return data, cls(data, kernel=kernel, centered=centered, unambiguous=unambiguous, 89 | **kernel_params) 90 | 91 | 92 | class KMedoids(qubo_embedding): 93 | """ 94 | K-medoids vector quantization problem: Given a dataset, find k representatives. 95 | For the derivation see: 96 | Christian Bauckhage et al., "A QUBO Formulation of the k-Medoids Problem.”, LWDA, 2019. 97 | 98 | Args: 99 | data_set (numpy.ndarray): Data points. 100 | distance_matrix (numpy.ndarray, optional): Pairwise distances between data points. 101 | Defaults to ``Welsh``-distance. 102 | k (int, optional): Number of representative points. Defaults to 2. 103 | alpha: (float, optional): Parameter controlling far apartness of k 104 | representatives. Defaults to 1 / k. 105 | beta: (float, optional): Parameter controlling far centrality of k 106 | representatives. Defaults to 1 / n. 107 | gamma: (float, optional): Parameter controlling the enforcement of exactly k 108 | representatives. Defaults to 2. 109 | """ 110 | def __init__(self, data_set=None, distance_matrix=None, k=2, alpha=None, beta=None, gamma=2): 111 | if data_set is None and distance_matrix is None: 112 | raise Exception('data_set or distance_matrix have to be given!') 113 | elif distance_matrix is None: 114 | distance_matrix = 1 - np.exp(- 0.5 * pairwise_distances(X=data_set, 115 | metric='sqeuclidean')) 116 | self.__data_set = data_set 117 | # map distance matrix to [0, 1] 118 | self.__distance_matrix = distance_matrix / distance_matrix.max() 119 | self.__n = self.__distance_matrix.shape[0] 120 | self.__k = k 121 | if alpha is None: 122 | alpha = 0.5 / self.__k 123 | self.__alpha = alpha 124 | if beta is None: 125 | beta = 1.0 / self.__n 126 | self.__beta = beta 127 | self.__gamma = gamma 128 | self.__Q = self.__from_data() 129 | 130 | @property 131 | def qubo(self): 132 | return self.__Q 133 | 134 | @property 135 | def data(self): 136 | return dict(points=self.__data_set, 137 | distance_matrix=self.__distance_matrix) 138 | 139 | def __from_data(self): 140 | # Identification of far apart data points 141 | far_apart_matrix = - self.__alpha * self.__distance_matrix 142 | # Identification of central data points 143 | ones_vector = np.ones(self.__n) 144 | central_vector = self.__beta * self.__distance_matrix @ ones_vector 145 | # Ensuring k representatives 146 | ensuring_matrix = self.__gamma * np.ones((self.__n, self.__n)) 147 | ensuring_vector = - 2 * self.__gamma * self.__k * ones_vector 148 | np.fill_diagonal(ensuring_matrix, np.diag(ensuring_matrix) + ensuring_vector) 149 | # Putting different objectives together 150 | matrix = far_apart_matrix + ensuring_matrix 151 | np.fill_diagonal(matrix, np.diag(matrix) + central_vector) 152 | return qubo(matrix) 153 | 154 | 155 | class SubsetSum(qubo_embedding): 156 | """Subset Sum problem: Given a list of values, find 157 | a subset that adds up to a given target value. 158 | 159 | Args: 160 | values (numpy.ndarray | list): Values of which to find a subset. 161 | The resulting QUBO instance will have size ``len(values)``. 162 | target (int | float): Target value which the subset must add up to. 163 | """ 164 | def __init__(self, values, target): 165 | self.__values = np.asarray(values) 166 | self.__target = target 167 | self.__Q = self.__from_data(values, target) 168 | 169 | @property 170 | def qubo(self): 171 | return self.__Q 172 | 173 | @property 174 | def data(self): 175 | return dict( 176 | values=self.__values, 177 | target=self.__target) 178 | 179 | def __from_data(self, values, target): 180 | q = np.outer(values, values) 181 | q[np.diag_indices_from(q)] -= 2 * target * values 182 | q = np.triu(q + np.tril(q, -1).T) 183 | return qubo(q) 184 | 185 | def map_solution(self, x): 186 | return self.__values[x.astype(bool)] 187 | 188 | @classmethod 189 | def random(cls, n: int, low=-100, high=100, 190 | summands=None, random_state=None): 191 | npr = get_random_state(random_state) 192 | values = np.zeros(n) 193 | while np.any(np.isclose(values, 0)): 194 | values = npr.uniform(low, high, size=n).round(2) 195 | k = round(npr.triangular(0.1*n, 0.5*n, 0.9*n)) if summands is None else summands 196 | subset = npr.permutation(n) < k 197 | target = values[subset].sum() 198 | return cls(values, target) 199 | 200 | 201 | class Max2Sat(qubo_embedding): 202 | """Maximum Satisfyability problem with clauses of size 2. 203 | The problem is to find a variable assignment that maximizes 204 | the number of true clauses. 205 | 206 | Args: 207 | clauses (list): A list of tuples containing literals, 208 | representing a logical formula in CNF. 209 | Each tuple must have exactly two elements. 210 | The elements must be integers, representing the variable 211 | indices **counting from 1**. Negative literals have a 212 | negative sign. For instance, the formula :math:`(x_1\\vee \\overline{x_2})\\wedge(\\overline{x_1}\\vee x_3)` 213 | becomes ``[(1,-2), (-1,3)]``. 214 | penalty (float, optional): Penalty value for unsatisfied clauses. 215 | Must be positive. Defaults to ``1.0``. 216 | """ 217 | def __init__(self, clauses, penalty=1.0): 218 | assert all(len(c) == 2 for c in clauses), 'All clauses must consist of exactly 2 variables' 219 | assert all(0 not in c for c in clauses), '"0" cannot be a variable, use indices >= 1' 220 | self.__clauses = clauses 221 | ix_set = set() 222 | ix_set.update(*self.__clauses) 223 | self.__indices = [i for i in sorted(ix_set) if i > 0] 224 | assert penalty > 0.0, 'Penalty must be positive > 0' 225 | self.__penalty = penalty 226 | 227 | def map_solution(self, x): 228 | return {i: x[self.__indices.find(i)] == 1 for i in self.__indices} 229 | 230 | @property 231 | def qubo(self): 232 | n = max(max(c) for c in self.__clauses) 233 | m = np.zeros((n, n)) 234 | ix_map = {i: qi for qi, i in enumerate(self.__indices)} 235 | for xi, xj in map(partial(sorted, key=abs), self.__clauses): 236 | i = ix_map(abs(xi)) 237 | j = ix_map(abs(xj)) 238 | if xi > 0: 239 | if xj > 0: 240 | m[i, i] += self.__penalty 241 | m[j, j] += self.__penalty 242 | m[i, j] -= self.__penalty 243 | else: 244 | m[j, j] += self.__penalty 245 | m[i, j] -= self.__penalty 246 | else: 247 | if xj > 0: 248 | m[i, i] += self.__penalty 249 | m[i, j] -= self.__penalty 250 | else: 251 | m[i, j] += self.__penalty 252 | return qubo(m) 253 | 254 | @property 255 | def data(self): 256 | return self.__clauses 257 | 258 | @classmethod 259 | def random(cls, n: int, clauses=None, random_state=None): 260 | npr = get_random_state(random_state) 261 | if clauses is None: 262 | clauses = int(1.5 * n) 263 | # TODO 264 | return NotImplemented 265 | 266 | 267 | class KernelSVM(qubo_embedding): 268 | """Kernel Support Vector Machine learning: Given labeled numerical data, 269 | and a kernel, determines the support vectors, which is a subset of the 270 | data that lie on the margin of a separating hyperplane in the feature space 271 | determined by the kernel. Note that this method makes some simplifying 272 | assumptions detailed in `this paper `__. 273 | 274 | Args: 275 | X (numpy.ndarray): Input data (row-wise) of shape ``(N, d)``. 276 | y (numpy.ndarray): Binary labels (-1 or 1) of shape ``(N,)`` 277 | C (float, optional): Hyperparameter controlling the penalization of 278 | misclassified data points. Defaults to 1. 279 | kernel (str, optional): Kernel function. Defaults to ``'linear'``. 280 | Can be any of `those `__. 281 | **kernel_params: Additional keyword arguments for the Kernel function, passed to ``sklearn.metrics.pairwise_kernels``. 282 | """ 283 | 284 | def __init__(self, X, y, C=1.0, kernel='linear', **kernel_params): 285 | self.__X = X 286 | self.__y = y 287 | self.__C = C 288 | self.__kernel = kernel 289 | self.__kernel_params = kernel_params 290 | 291 | @property 292 | def data(self): 293 | return dict(X=self.__X, y=self.__y) 294 | 295 | @property 296 | def qubo(self): 297 | K = pairwise_kernels( 298 | X=self.__X, 299 | metric=self.__kernel, 300 | **self.__kernel_params) 301 | m = 0.5*(self.__C**2)*K*np.outer(self.__y, self.__y) 302 | m += np.tril(m, -1).T 303 | m -= self.__C*np.eye(K.shape[0]) 304 | return qubo(np.triu(m)) 305 | 306 | def map_solution(self, x): 307 | return np.where(x)[0] -------------------------------------------------------------------------------- /qubolite/_c_utils.c: -------------------------------------------------------------------------------- 1 | #define NPY_NO_DEPRECATED_API NPY_1_7_API_VERSION 2 | #include 3 | #include 4 | #include 5 | #include 6 | #include 7 | #include 8 | 9 | typedef unsigned char bit; 10 | 11 | void print_bits(bit *x, size_t n) { 12 | for (size_t i=0; i0 ? 1 : 0; 60 | double val = qubo_score(qubo, x, n); // QUBO value 61 | double dval; // QUBO value update 62 | 63 | bit *min_x = (bit*) malloc(n); 64 | memcpy(min_x, x, n); 65 | double min_vals[2] = {val, INFINITY}; 66 | 67 | size_t i, j; 68 | // make sure it_lim is correctly set 69 | int64_t it_lim = (1ULL<min_vals[0]) 96 | min_vals[1] = val; 97 | } 98 | brute_force_result res = {min_x, min_vals[0], min_vals[1]}; 99 | free(x); 100 | return res; 101 | } 102 | 103 | PyObject *py_brute_force(PyObject *self, PyObject *args) { 104 | #ifndef __APPLE__ 105 | const size_t MAX_THREADS = omp_get_max_threads(); 106 | #else 107 | const size_t MAX_THREADS = 1; 108 | #endif 109 | PyArrayObject *arr; 110 | size_t max_threads = MAX_THREADS; 111 | PyArg_ParseTuple(args, "O|k", &arr, &max_threads); 112 | if (PyErr_Occurred() || !PyArray_Check(arr)) 113 | return NULL; 114 | 115 | const size_t n = PyArray_DIM(arr, 0); 116 | double **qubo; 117 | npy_intp dims[] = { [0] = n, [1] = n }; 118 | PyArray_AsCArray((PyObject**) &arr, &qubo, dims, 2, 119 | PyArray_DescrFromType(NPY_DOUBLE)); 120 | if (PyErr_Occurred()) 121 | return NULL; 122 | 123 | #ifdef _MSC_VER 124 | size_t m = 63-__lzcnt64(max_threads); // floor(log2(MAX_THREADS)) 125 | #else 126 | size_t m = 63-__builtin_clzll(max_threads); // floor(log2(MAX_THREADS)) 127 | #endif 128 | 129 | // ensure that the number of bits to optimize is positive 130 | if (n<=m) m = n-1; 131 | 132 | // check if n is too large and would cause an 133 | // overflow of size64_t 134 | if (n-m>=64) { 135 | // return None 136 | Py_INCREF(Py_None); 137 | return Py_None; 138 | } 139 | 140 | const size_t M = 1ULL<>1)+1; 171 | } else if (all_vals[j]>global_min_val0) { 172 | global_min_val1 = all_vals[j]; 173 | } 174 | } 175 | } 176 | 177 | // prepare return values 178 | PyObject *min_x_obj = PyArray_SimpleNew(1, &n, NPY_DOUBLE); 179 | double *min_x_obj_data = PyArray_DATA((PyArrayObject*) min_x_obj); 180 | bit *global_min_x = ress[global_min_ix].min_x; 181 | for (size_t j=0; j`__ 121 | between this sample and another. The Hellinger distance between two 122 | discrete probability distributions :math:`p` and :math:`q` is defined as 123 | 124 | :math:`\\frac{1}{\\sqrt{2}}\\sqrt{\\sum_{x\\in\\lbrace 0,1\\rbrace^n}(\\sqrt{p(x)}-\\sqrt{q(x)})^2}`. 125 | 126 | In contrast to KL divergence, Hellinger distance is an actual distance, 127 | in that it is symmetrical. 128 | 129 | Args: 130 | other (BinarySample): Binary sample to compare against. 131 | 132 | Returns: 133 | float: Hellinger distance between the two samples. 134 | """ 135 | assert self.n == other.n 136 | xs = set(self.counts.keys()) 137 | xs.update(other.counts.keys()) 138 | xs = list(xs) 139 | p1 = np.asarray([self.counts.get(x, 0) for x in xs], dtype=np.float64)/self.size 140 | p2 = np.asarray([other.counts.get(x, 0) for x in xs], dtype=np.float64)/other.size 141 | return np.linalg.norm(np.sqrt(p1)-np.sqrt(p2))/np.sqrt(2.0) 142 | 143 | def subsample(self, size: int, random_state=None): 144 | """Return a subsample of this sample instance of given size, 145 | i.e., a subset of the observed raw bit vectors. 146 | 147 | Args: 148 | size (int): Size of the subsample. 149 | random_state (optional): A numerical or lexical seed, or a NumPy random generator. Defaults to None. 150 | 151 | Returns: 152 | BinarySample: Subsample. 153 | """ 154 | npr = get_random_state(random_state) 155 | xs = list(sorted(self.counts.keys())) # sort for reproducibility 156 | cumcs = np.cumsum(np.asarray([self.counts[x] for x in xs])) 157 | mask = npr.permutation(self.size) < size 158 | counts = dict() 159 | for u, v, x in zip(np.r_[0, cumcs], cumcs, xs): 160 | c = mask[u:v].sum() 161 | if c > 0: 162 | counts[x] = c 163 | return BinarySample(counts=counts) 164 | 165 | def most_frequent(self): 166 | """Return the binary string that was observed most often. 167 | 168 | Returns: 169 | str: Most frequent binary string. 170 | """ 171 | return max(self.counts, key=self.counts.get) 172 | 173 | def empirical_prob(self, x=None): 174 | """Return the empirical probability of observing a given 175 | binary string w.r.t. this sample. If no argument is 176 | provided, return a vector containing all empirical 177 | probabilities of all ``n``-bit vectors in lexicographical 178 | order. 179 | 180 | Args: 181 | x (str, optional): Binary string to get the empirical probability for. Defaults to None. 182 | 183 | Returns: 184 | Empirical probability (float), or vector of shape ``(2**n,)`` containing all probabilities. 185 | """ 186 | if x is None: 187 | return self.__emp_prob 188 | c = self.counts.get(x, 0) 189 | return c/self.size 190 | 191 | @cached_property 192 | def __emp_prob(self): 193 | P = np.zeros(1<= burn_in: 236 | counts[to_string(x)] += 1 237 | return BinarySample(counts=dict(counts)) 238 | 239 | 240 | def gibbs(Q: qubo, samples=1, burn_in = 100, keep_interval=100, max_threads=256, temp=1.0, return_raw=False, random_state=None): 241 | """Perform Gibbs sampling on the Gibbs distribution induced by 242 | the given QUBO instance. This method builds upon a Markov chain 243 | that converges to the true distribution after a certain number 244 | of iterations (*burn-in* phase). The longer the initial burn-in 245 | phase, the higher the sample quality. A caveat of this method 246 | is that subsequent samples are not independent, which is why 247 | most samples are discarded (see ``keep_interval`` below). 248 | 249 | Args: 250 | Q (qubo): QUBO instance. 251 | samples (int, optional): Sample size. Defaults to 1. 252 | burn_in (int, optional): Number of initial iterations that are discarded, 253 | the so-called *burn-in* phase. Defaults to 100. 254 | keep_interval (int, optional): Number of samples out of which 255 | only one is kept, and the others discarded. Choosing a high 256 | value makes the samples more independent, but slows down 257 | the sampling procedure. Defaults to 100. 258 | max_threads (int): Upper limit for the number of threads. Defaults to 259 | 256. This value is capped to the actual number of hardware threads. 260 | temp (float, optional): Temperature parameter of the Gibbs distribution. Defaults to 1.0. 261 | return_raw (bool, optional): If true, returns the raw Gibbs samples without wrapping them 262 | in a BinarySample object. Defaults to false. 263 | random_state (optional): A numerical or lexical seed, or a NumPy random generator. Defaults to None. 264 | 265 | Returns: 266 | BinarySample: Random sample. 267 | """ 268 | rng = get_random_state(random_state) 269 | bitgencaps = [r.bit_generator.capsule for r in rng.spawn(max_threads)] 270 | sample = _gibbs_sample_c(Q.m/temp, bitgencaps, samples, burn_in, max_threads, keep_interval) 271 | return sample if return_raw else BinarySample(raw=sample) 272 | 273 | ################################################################################ 274 | # Train QUBO parameters # 275 | ################################################################################ 276 | 277 | 278 | class exponential_learning_rate: 279 | def __init__(self, η0, η1): 280 | self.η0 = η0 281 | self.factor = (np.log(η1)/np.log(η0))-1 282 | 283 | def get_lr(self, t=0.0): 284 | return self.η0**(1+t*self.factor) 285 | 286 | 287 | def train_gibbs( 288 | target: BinarySample, 289 | steps=1000, 290 | temperature=1.0, 291 | lr_schedule=None, 292 | return_hellinger_distances=False, 293 | random_state=None, 294 | silent=False): 295 | npr = get_random_state(random_state) 296 | # initialize QUBO matrix 297 | Q = qubo(np.zeros((target.n, target.n))) 298 | # learning rate schedule 299 | if lr_schedule is None: 300 | lr_schedule = exponential_learning_rate(1e-1, 1e-4) 301 | # space for Hellinger distances over time 302 | hds = np.empty(steps) 303 | 304 | progress = mock() if silent else tqdm(total=steps, desc='Train') 305 | for i in range(steps): 306 | # draw Gibbs samples 307 | sample = gibbs(Q, 308 | samples=target.size, 309 | burn_in=1000, 310 | temp=temperature, 311 | random_state=npr) 312 | hds[i] = target.hellinger_distance(sample) 313 | # get gradient according to Nico's formula 314 | Δ = (sample.suff_stat-target.suff_stat)/target.size 315 | # update QUBO parameters (perform gradient ascent) 316 | η = lr_schedule.get_lr(i/(steps-1)) 317 | Q.m += η*Δ 318 | progress.set_postfix({'LR': η, 'HD': hds[i]}, refresh=False) 319 | progress.update(1) 320 | return (Q, hds) if return_hellinger_distances else Q -------------------------------------------------------------------------------- /qubolite/solving.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | 3 | import numpy as np 4 | 5 | from ._misc import get_random_state, warn_size 6 | from .bitvec import flip_index 7 | from .qubo import qubo 8 | from .sampling import BinarySample 9 | 10 | from _c_utils import brute_force as _brute_force_c 11 | from _c_utils import anneal as _anneal_c 12 | 13 | 14 | solution_t = namedtuple('qubo_solution', ['x', 'energy']) 15 | 16 | 17 | def brute_force(Q: qubo, max_threads=256): 18 | """Solve QUBO instance exactly by brute force. Note that this method is 19 | infeasible for instances with a size beyond around 30. 20 | 21 | Args: 22 | Q (qubo): QUBO instance to solve. 23 | max_threads (int): Upper limit for the number of threads. Defaults to 24 | 256. 25 | 26 | Raises: 27 | ValueError: Raised if the QUBO size is too large to be brute-forced on 28 | the present system. 29 | 30 | Returns: 31 | A tuple containing the minimizing vector (numpy.ndarray) and the minimal 32 | energy (float). 33 | """ 34 | warn_size(Q.n, limit=30) 35 | try: 36 | x, v, _ = _brute_force_c(Q.m, max_threads) 37 | except TypeError: 38 | raise ValueError(f'n is too large to brute-force on this system') 39 | return solution_t(x, v) 40 | 41 | 42 | def simulated_annealing(Q: qubo, 43 | schedule='2+', 44 | halftime=0.25, 45 | steps=100_000, 46 | init_temp=None, 47 | n_parallel=10, 48 | random_state=None): 49 | """Performs simulated annealing to approximate the minimizing vector and 50 | minimal energy of a given QUBO instance. 51 | 52 | Args: 53 | Q (qubo): QUBO instance. 54 | schedule (str, optional): The annealing schedule to employ. Possible 55 | values are: ``2+`` (quadratic additive), ``2*`` (quadratic 56 | multiplicative), ``e+`` (exponential additive) and ``e*`` 57 | (exponential multiplicative). See 58 | `here `__ 59 | for further infos. Defaults to '2+'. 60 | halftime (float, optional): For multiplicative schedules only: The 61 | percentage of steps after which the temperature is halved. Defaults 62 | to 0.25. 63 | steps (int, optional): Number of annealing steps to perform. Defaults to 64 | 100_000. 65 | init_temp (float, optional): Initial temperature. Defaults to None, 66 | which estimates an initial temperature. 67 | n_parallel (int, optional): Number of random initial solutions to anneal 68 | simultaneously. Defaults to 10. 69 | random_state (optional): A numerical or lexical seed, or a NumPy random 70 | generator. Defaults to None. 71 | 72 | Raises: 73 | ValueError: Raised if the specificed schedule is unknown. 74 | 75 | Returns: 76 | A tuple ``(x, y)`` containing the solution bit vectors and their 77 | respective energies. The shape of ``x`` is ``(n_parallel, n)``, where 78 | ``n`` is the QUBO size; the shape of ``y`` is ``(n_parallel,)``. Bit 79 | vector ``x[i]`` has energy ``y[i]`` for each ``i``. 80 | """ 81 | npr = get_random_state(random_state) 82 | if init_temp is None: 83 | # estimate initial temperature 84 | EΔy, k = 0, 0 85 | for _ in range(1000): 86 | x = npr.random(Q.n) < 0.5 87 | Δy = Q.dx(x) 88 | ix, = np.where(Δy > 0) 89 | EΔy += Δy[ix].sum() 90 | k += ix.size 91 | EΔy /= k 92 | initial_acc_prob = 0.99 93 | init_temp = -EΔy / np.log(initial_acc_prob) 94 | print(f'Init. temp. automatically set to {init_temp:.4f}') 95 | 96 | # setup cooling schedule 97 | if schedule == 'e+': 98 | temps = init_temp/(1+np.exp(2*np.log(init_temp)*(np.linspace(0, 1, steps+1)-0.5))) 99 | elif schedule == '2+': 100 | temps = init_temp*(1-np.linspace(0, 1, steps+1))**2 101 | elif schedule == 'e*': 102 | temps = init_temp*(0.5**(1/halftime))**np.arange(0, 1, steps+1) 103 | elif schedule == '2*': 104 | temps = init_temp/(1+(1/(halftime**2))*np.linspace(0, 1, steps+1)**2) 105 | else: 106 | raise ValueError('Unknown schedule; must be one of {e*, 2*, e+, 2+}.') 107 | 108 | x = (npr.random((n_parallel, Q.n)) < 0.5).astype(np.float64) 109 | y = Q(x) 110 | for temp in temps: 111 | z = npr.random((n_parallel, Q.n)) < (1 / Q.n) 112 | x_ = (x + z) % 2 113 | Δy = Q(x_) - y 114 | p = np.minimum(np.exp(-Δy / temp), 1) 115 | a = npr.random(n_parallel) < p 116 | x = x + (x_ - x) * a[:, None] 117 | y = y + Δy * a 118 | 119 | srt = np.argsort(y) 120 | return x[srt, :], y[srt] 121 | 122 | 123 | def local_descent(Q: qubo, x=None, random_state=None): 124 | """Starting from a given bit vector, find improvements in the 1-neighborhood 125 | and follow them until a local optimum is found. If no initial vector is 126 | specified, a random vector is sampled. At each step, the method greedily 127 | flips the bit that yields the greatest energy improvement. 128 | 129 | Args: 130 | Q (qubo): QUBO instance. 131 | x (numpy.ndarray, optional): Initial bit vector. Defaults to None. 132 | random_state (optional): A numerical or lexical seed, or a NumPy random 133 | generator. Defaults to None. 134 | 135 | Returns: 136 | A tuple containing the bit vector (numpy.ndarray) with lowest energy 137 | found, and its energy (float). 138 | """ 139 | if x is None: 140 | rng = get_random_state(random_state) 141 | x_ = rng.random(Q.n) < 0.5 142 | else: 143 | x_ = x.copy() 144 | while True: 145 | Δx = Q.dx(x_) 146 | am = np.argmin(Δx) 147 | if Δx[am] >= 0: 148 | break 149 | x_[am] = 1 - x_[am] 150 | return solution_t(x_, Q(x_)) 151 | 152 | 153 | def local2_descent(Q: qubo, x=None, random_state=None): 154 | """Starting from a given bit vector, find improvements in the 2-neighborhood 155 | and follow them until a local optimum is found. If no initial vector is 156 | specified, a random vector is sampled. At each step, the method greedily 157 | flips up to two bits that yield the greatest energy improvement. 158 | 159 | Args: 160 | Q (qubo): QUBO instance. 161 | x (numpy.ndarray, optional): Initial bit vector. Defaults to None. 162 | random_state (optional): A numerical or lexical seed, or a NumPy random 163 | generator. Defaults to None. 164 | 165 | Returns: 166 | A tuple containing the bit vector (numpy.ndarray) with lowest energy 167 | found, and its energy (float). 168 | """ 169 | if x is None: 170 | rng = get_random_state(random_state) 171 | x_ = rng.random(Q.n) < 0.5 172 | else: 173 | x_ = x.copy() 174 | Δx = Q.dx2(x_) # (n, n) matrix 175 | i, j = np.unravel_index(np.argmin(Δx), Δx.shape) 176 | while True: 177 | Δx = Q.dx2(x_) 178 | i, j = np.unravel_index(np.argmin(Δx), Δx.shape) 179 | if Δx[i, j] >= 0: 180 | break 181 | flip_index(x_, [i, j], in_place=True) 182 | return solution_t(x_, Q(x_)) 183 | 184 | 185 | def local_descent_search(Q: qubo, steps=1000, random_state=None): 186 | """Perform local descent in a multistart fashion and return the lowest 187 | observed bit vector. Use the 1-neighborhood as search radius. 188 | 189 | Args: 190 | Q (qubo): QUBO instance. 191 | steps (int, optional): Number of multistarts. Defaults to 1000. 192 | random_state (optional): A numerical or lexical seed, or a NumPy random 193 | generator. Defaults to None. 194 | 195 | Returns: 196 | A tuple containing the bit vector (numpy.ndarray) with lowest energy 197 | found, and its energy (float). 198 | """ 199 | rng = get_random_state(random_state) 200 | x_min = np.empty(Q.n) 201 | y_min = np.infty 202 | x = np.empty(Q.n) 203 | for _ in range(steps): 204 | x[:] = rng.random(Q.n) < 0.5 205 | while True: 206 | Δx = Q.dx(x) # (n,) vector 207 | am = np.argmin(Δx, axis=-1) 208 | if Δx[am] >= 0: 209 | break 210 | x[am] = 1 - x[am] 211 | y = Q(x) 212 | if y <= y_min: 213 | x_min[:] = x 214 | y_min = y 215 | return solution_t(x_min, y_min) 216 | 217 | 218 | def local2_descent_search(Q: qubo, steps=1000, random_state=None): 219 | """Perform local descent in a multistart fashion and return the lowest 220 | observed bit vector. Use the 2-neighborhood as search radius. 221 | 222 | Args: 223 | Q (qubo): QUBO instance. 224 | steps (int, optional): Number of multistarts. Defaults to 1000. 225 | random_state (optional): A numerical or lexical seed, or a NumPy random 226 | generator. Defaults to None. 227 | 228 | Returns: 229 | A tuple containing the bit vector (numpy.ndarray) with lowest energy 230 | found, and its energy (float). 231 | """ 232 | rng = get_random_state(random_state) 233 | x_min = np.empty(Q.n) 234 | y_min = np.infty 235 | x = np.empty(Q.n) 236 | for _ in range(steps): 237 | x[:] = rng.random(Q.n) < 0.5 238 | while True: 239 | Δx = Q.dx2(x) # (n, n) matrix 240 | i, j = np.unravel_index(np.argmin(Δx), Δx.shape) 241 | if Δx[i, j] >= 0: 242 | break 243 | flip_index(x, [i, j], in_place=True) 244 | y = Q(x) 245 | if y <= y_min: 246 | x_min[:] = x 247 | y_min = y 248 | return solution_t(x_min, y_min) 249 | 250 | 251 | def random_search(Q: qubo, steps=100_000, n_parallel=None, random_state=None): 252 | """Perform a random search in the space of bit vectors and return the 253 | lowest-energy solution found. 254 | 255 | Args: 256 | Q (qubo): QUBO instance. 257 | steps (int, optional): Number of steps to perform. Defaults to 100_000. 258 | n_parallel (int, optional): Number of random bit vectors to sample at a 259 | time. This does *not* increase the number of bit vectors sampled in 260 | total (specified by ``steps``), but makes the procedure faster by 261 | using NumPy vectorization. Defaults to None, which chooses a value 262 | such that the resulting bit vector array has about 32k elements. 263 | random_state (optional): A numerical or lexical seed, or a NumPy random 264 | generator. Defaults to None. 265 | 266 | Returns: 267 | A tuple containing the bit vector (numpy.ndarray) with lowest energy 268 | found, and its energy (float). 269 | """ 270 | rng = get_random_state(random_state) 271 | if n_parallel is None: 272 | n_parallel = 32_000 // Q.n 273 | x_min = np.empty(Q.n) 274 | y_min = np.infty 275 | remaining = steps 276 | x = np.empty((n_parallel, Q.n)) 277 | y = np.empty(n_parallel) 278 | while remaining > 0: 279 | r = min(remaining, n_parallel) 280 | x[:r] = rng.random((r, Q.n)) < 0.5 281 | y[:] = Q(x) 282 | i_min = np.argmin(y) 283 | if y[i_min] < y_min: 284 | x_min[:] = x[i_min, :] 285 | y_min = y[i_min] 286 | remaining -= r 287 | return solution_t(x_min, y_min) 288 | 289 | 290 | def subspace_search(Q: qubo, steps=1000, random_state=None, max_threads=256): 291 | """Perform search heuristic where :math:`n-\\log_2(n)` randomly selected 292 | variables are fixed and the remaining :math:`\\log_2(n)` bits are solved by 293 | brute force. The current solution is updated with the optimal sub-vector 294 | assignment, and the process is repeated. 295 | 296 | Args: 297 | Q (qubo): QUBO instance. 298 | steps (int, optional): Number of repetitions. Defaults to 1000. 299 | random_state (optional): A numerical or lexical seed, or a NumPy random 300 | generator. Defaults to None. 301 | max_threads (int): Upper limit for the number of threads created by the 302 | brute-force solver. Defaults to 256. 303 | 304 | Returns: 305 | A tuple containing the minimizing vector (numpy.ndarray) and the minimal 306 | energy (float). 307 | """ 308 | rng = get_random_state(random_state) 309 | log_n = int(np.log2(Q.n)) 310 | variables = np.arange(Q.n).astype(int) 311 | # sample random initial solution 312 | x = (rng.random(Q.n) < 0.5).astype(np.float64) 313 | for _ in range(steps): 314 | # fix random subset of n - log(n) variables 315 | rng.shuffle(variables) 316 | fixed = variables[:Q.n-log_n] 317 | Q_sub, _, free = Q.clamp(dict(zip(fixed, x[fixed]))) 318 | # find optimum in subspace by brute force 319 | x_sub_opt, *_ = _brute_force_c(Q_sub.m, max_threads) 320 | # set variables in current solution to subspace-optimal bits 321 | x[free] = x_sub_opt 322 | return solution_t(x, Q(x)) 323 | 324 | 325 | def anneal(Q: qubo, samples=1, iters = 100, max_threads=256, return_raw=False, random_state=None): 326 | """Perform Gibbs sampling with magic annealing schedule. 327 | 328 | Args: 329 | Q (qubo): QUBO instance. 330 | samples (int, optional): Sample size. Defaults to 1. 331 | iters (int, optional): Number of iterations. Defaults to 100. 332 | max_threads (int): Upper limit for the number of threads. Defaults to 333 | 256. This value is capped to the actual number of hardware threads. 334 | return_raw (bool, optional): If true, returns the raw Gibbs samples without wrapping them 335 | in a BinarySample object. Defaults to false. 336 | random_state (optional): A numerical or lexical seed, or a NumPy random generator. Defaults to None. 337 | 338 | Returns: 339 | BinarySample: Random sample. 340 | """ 341 | rng = get_random_state(random_state) 342 | bitgencaps = [r.bit_generator.capsule for r in rng.spawn(max_threads)] 343 | sample = _anneal_c(Q.m, bitgencaps, samples, iters, max_threads, iters) 344 | return sample if return_raw else BinarySample(raw=sample) 345 | -------------------------------------------------------------------------------- /qubolite/assignment.py: -------------------------------------------------------------------------------- 1 | import re 2 | from functools import reduce 3 | from itertools import combinations, groupby, repeat 4 | from operator import methodcaller, xor 5 | 6 | import networkx as nx 7 | import numpy as np 8 | 9 | from . import qubo 10 | from ._misc import get_random_state, make_upper_triangle, to_shape 11 | 12 | 13 | def _follow_edges(G: nx.DiGraph, u): 14 | v, inv = u, False 15 | while True: 16 | try: 17 | _, v, data = next(iter(G.edges(v, data=True))) 18 | inv ^= data['inverse'] 19 | except StopIteration: 20 | break 21 | return v, inv 22 | 23 | 24 | class partial_assignment: 25 | """This class represents bit vectors of a fixed size ``n`` where a number of 26 | bits at certain positions are either fixed to a constant value (0 or 1), or 27 | tied to the value of another bit (or its inverse). The bits that are not 28 | fixed or tied are called *free variables*. 29 | 30 | The preferred way to instantiate a partial assignment is through the str 31 | argument or through a bit vector expression (see :meth:`assignment.partial_assignment.from_expression`). 32 | However, you can specify a partial assignment graph using the ``graph`` argument. 33 | 34 | Args: 35 | s (str, optional): String representation of a partial assignment; see 36 | examples below for the format. 37 | n (int, optional): The minimum number of bits of the bit vector; e.g., 38 | if only ``x2 = 1`` is specified, by setting ``n=5``, the partial 39 | assignment will have 5 bits (i.e., ``**1**``). If None (default), 40 | use the highest bit index to determine the size. If the highest 41 | index is greater than ``n``, then it will be used instead of ``n``. 42 | graph (networkx.DiGraph, optional): Directed graph representing a partial variable 43 | assignment. The nodes must be labeled ``"x0"``, ``"x1"``, etc., up to 44 | some ``n-1`` for all bit variables. Additionally, there must be a 45 | node labeled ``"1"``. An edge from ``"x3"`` to ``"1"`` means that 46 | bit 3 is fixed to 1, and an edge from ``"x5"`` to ``"x4"`` means that 47 | bit 5 is tied to bit 4. Every edge must have a boolean attribute 48 | ``inverse`` which indicates if the relation holds inversely, i.e., 49 | an edge from ``"x3"`` to ``"x4"`` with ``inverse=True`` means that 50 | bit 3 is always the opposite of bit 4. The preferred way to create 51 | instances is through ``from_expression`` or ``from_string``. Only 52 | use the constructor if you know what you are doing. If specified, 53 | ``s`` and ``n`` will be ignored. 54 | 55 | Examples: 56 | The string representation of a partial assignment consists of a list of 57 | bit assignments separated by semicola (``;``). A bit assignment consists 58 | of a variable or a comma-separated list of bit variables followed by ``=`` 59 | or ``!=`` and then a single bit variable or ``0`` or ``1``. A bit 60 | variable consists of the letter ``x`` followed by a non-negative integer 61 | denoting the bit index. Additionally you can specify ranges of 62 | consecutive bit variables like ``x{-}``, where ```` and ```` 63 | are the start and stop index (inclusive) respectively. 64 | The following lines are all valid strings: 65 | 66 | x0 = 1 67 | x2, x3, x5 != x8; x6 = 0 68 | x4=x3;x10=1 69 | x{2-6}, x8 = x0; x7 != x0 70 | 71 | The partial assignment can then be instantiated like this: 72 | 73 | >>> PA = partial_assignment('x4!=x5; x1=0', n=10) 74 | >>> PA 75 | x1 = 0; x5 != x4 76 | >>> PA.to_expression() 77 | *0***[!4]**** 78 | """ 79 | 80 | __BITVEC_EXPR = re.compile(r'[01*]|\[!?\d+\]|\{\d+\}') 81 | __NODE_NAME_PATTERN = re.compile(r'(x(0$|([1-9]\d*)))|1$') 82 | 83 | def __init__(self, s: str=None, n: int=None, *, graph: nx.DiGraph=None): 84 | if graph is not None: 85 | # check if `graph` is a valid PAG (=Partial Assignment Graph) 86 | assert all(self.__NODE_NAME_PATTERN.match(u) is not None for u in graph.nodes), \ 87 | '`graph` contains invalid node names' 88 | self.__PAG = graph 89 | else: 90 | self.__PAG = self.__from_string(s, n) 91 | self.__dirty = True 92 | self.grouping_limit = 10 93 | 94 | def __normalize_graph(self, G: nx.DiGraph): 95 | for nodes in nx.simple_cycles(G): 96 | edges = list(zip(nodes, nodes[1:]+[nodes[0]])) 97 | conflict = reduce(xor, [G.get_edge_data(*e)['inverse'] for e in edges]) 98 | if conflict: 99 | raise RuntimeError('Partial Assignment Graph contains conflicting cycle!') 100 | # resolve cycle by deleting an edge 101 | G.remove_edge(*edges[0]) 102 | # resolve chained references as far as possible 103 | # TODO: This could be more efficient… 104 | for node in G.nodes: 105 | u, inv = _follow_edges(G, node) 106 | if u != node: 107 | v, = G.neighbors(node) 108 | G.remove_edge(node, v) 109 | # ensure that higher indices always point to lower indices 110 | if u != '1' and int(node[1:]) < int(u[1:]): 111 | # reverse edge 112 | G.add_edge(u, node, inverse=inv) 113 | # flip all edges incident to u 114 | for w, _, data in list(G.in_edges(u, data=True)): 115 | G.remove_edge(w, u) 116 | G.add_edge(w, node, **data) 117 | else: 118 | G.add_edge(node, u, inverse=inv) 119 | return G 120 | 121 | def __dirty(func): 122 | def go(self, *args, **kwargs): 123 | self.__dirty = True 124 | return func(self, *args, **kwargs) 125 | return go 126 | 127 | def __assert_normalized(func): 128 | def go(self, *args, **kwargs): 129 | dirty = getattr(self, '__dirty', True) 130 | if dirty: 131 | self.__PAG = self.__normalize_graph(self.__PAG) 132 | self.__dirty = False 133 | return func(self, *args, **kwargs) 134 | return go 135 | 136 | @__assert_normalized 137 | def __repr__(self): 138 | s = '' 139 | const_zero, const_one = [], [] 140 | for u, _, data in self.__PAG.in_edges('1', data=True): 141 | (const_zero if data['inverse'] else const_one).append(u) 142 | if const_zero: s += ', '.join(const_zero) + ' = 0; ' 143 | if const_one: s += ', '.join(const_one) + ' = 1; ' 144 | for v in self.__PAG.nodes(): 145 | if v == '1': continue 146 | us_pos, us_neg = [], [] 147 | for u, _, data in self.__PAG.in_edges(v, data=True): 148 | (us_neg if data['inverse'] else us_pos).append(u) 149 | if us_pos: s += ', '.join(us_pos) + ' = ' + v + '; ' 150 | if us_neg: s += ', '.join(us_neg) + ' != ' + v + '; ' 151 | return s[:-2] # remove trailing separator 152 | 153 | def __from_string(self, s: str, n: int=None): 154 | def unpack_ranges(nodes): 155 | # handle expressions like 'x{1-5}' 156 | for node in nodes: 157 | if '{' in node: 158 | start, stop = node[2:-1].split('-') 159 | yield from [f'x{i}' for i in range(int(start), int(stop)+1)] 160 | else: 161 | yield node 162 | PAG = nx.DiGraph() 163 | PAG.add_node('1') 164 | assignments = s.split(';') 165 | for assignment in assignments: 166 | try: 167 | left, right = assignment.split('!=') 168 | inv = True 169 | except ValueError: 170 | if assignment.strip() == '': 171 | continue 172 | left, right = assignment.split('=') 173 | inv = False 174 | nodes_left = map(methodcaller('strip'), left.split(',')) 175 | node_right = right.strip() 176 | if node_right == '0': 177 | v = '1' 178 | inv = True 179 | else: 180 | v = node_right 181 | PAG.add_edges_from([(u, v, {'inverse': inv}) 182 | for u in unpack_ranges(nodes_left)]) 183 | # add potentially missing nodes 184 | n_ = 1 + int(max([u for u in PAG.nodes() if u!='1'], 185 | key=lambda x: int(x[1:]))[1:]) 186 | n = n_ if n is None else max(n, n_) 187 | PAG.add_nodes_from([f'x{i}' for i in range(n)]) 188 | return PAG 189 | 190 | @property 191 | def size(self): 192 | """Total number of bits described by the partial assignment, including 193 | fixed/tied and free bits. 194 | 195 | Returns: 196 | int: Number of bits. 197 | """ 198 | return self.__PAG.number_of_nodes()-1 199 | 200 | @property 201 | @__assert_normalized 202 | def free(self): 203 | """List of variable indices that are not fixed. 204 | 205 | Returns: 206 | np.ndarray: Free indices. 207 | """ 208 | free_nodes = set(range(self.size)).difference([int(u[1:]) for u, _ in self.__PAG.edges]) 209 | return np.fromiter(sorted(free_nodes), dtype=int) 210 | 211 | @property 212 | @__assert_normalized 213 | def num_free(self): 214 | """Number of free variables, i.e., variables that are not fixed. 215 | 216 | Returns: 217 | int: Number of free variables. 218 | """ 219 | nodes_with_out_edges = { u for u, _ in self.__PAG.edges } 220 | return self.__PAG.number_of_nodes()-len(nodes_with_out_edges)-1 221 | 222 | @property 223 | @__assert_normalized 224 | def fixed(self): 225 | """List of variable indices that are fixed. 226 | 227 | Returns: 228 | np.ndarray: Fixed indices. 229 | """ 230 | nodes_with_out_edges = { int(u[1:]) for u, _ in self.__PAG.edges } 231 | return np.fromiter(sorted(nodes_with_out_edges), dtype=int) 232 | 233 | @property 234 | @__assert_normalized 235 | def num_fixed(self): 236 | """Number of variables that are fixed. 237 | 238 | Returns: 239 | int: Number of fixed variables. 240 | """ 241 | nodes_with_out_edges = { u for u, _ in self.__PAG.edges } 242 | return len(nodes_with_out_edges) 243 | 244 | @__dirty 245 | def assign_constant(self, u: int, const): 246 | """Fix a bit at the given index to a constant 0 or 1. If the index is 247 | larger than the current size, add all intermediate bits. 248 | 249 | Args: 250 | u (int): Bit index. 251 | const (int or str): Constant value; must be 0 or 1. 252 | """ 253 | const_ = str(int(const)) 254 | assert const_ in '01', '`const` must be 0 or 1' 255 | self.__PAG.add_nodes_from([f'x{i}' for i in range(self.size, u)]) 256 | self.__PAG.remove_edges_from(list(self.__PAG.out_edges(f'x{u}'))) 257 | self.__PAG.add_edge(f'x{u}', '1', inverse=const_=='0') 258 | 259 | @__dirty 260 | def assign_index(self, u: int, v: int, inverse=False): 261 | """Tie a bit to another. If either of the indices is larger than the 262 | current size, add all intermediate bits. 263 | 264 | Args: 265 | u (int): Bit index to tie to another index 266 | v (int): Second bit index that ``u`` is tied to. 267 | inverse (bool, optional): Indicates if the first bit is tied 268 | inversely to the second. Defaults to False. 269 | """ 270 | self.__PAG.add_nodes_from([f'x{i}' for i in range(self.size, max(u, v))]) 271 | self.__PAG.remove_edges_from(list(self.__PAG.out_edges(f'x{u}'))) 272 | self.__PAG.add_edge(f'x{u}', f'x{v}', inverse=inverse) 273 | 274 | @__assert_normalized 275 | def to_expression(self): 276 | """Inverse operation of ``from_expression``, creates a bit vector 277 | expression representing this partial assignment. See there for detailed 278 | info about these expressions. 279 | By default, grouping is used for 10 or more identical tokens in a row. 280 | To change this, set the ``grouping_limit`` property to a positive integer 281 | value. 282 | 283 | Returns: 284 | str: Bit vector expression. 285 | """ 286 | n = self.__PAG.number_of_nodes()-1 287 | nodes = [f'x{i}' for i in range(n)] 288 | tokens = [] 289 | for u in nodes: 290 | try: 291 | _, v, data = next(iter(self.__PAG.edges(u, data=True))) 292 | if v == '1': 293 | tokens.append('0' if data['inverse'] else '1') 294 | else: 295 | tokens.append(f'[{"!" if data["inverse"] else ""}{v[1:]}]') 296 | except StopIteration: 297 | tokens.append('*') 298 | # group tokens 299 | expr = '' 300 | for token, g in groupby(tokens): 301 | rep = len(list(g)) 302 | if rep >= self.grouping_limit: 303 | expr += f'{token}{{{rep}}}' 304 | else: 305 | expr += token*rep 306 | return expr 307 | 308 | @classmethod 309 | def _ungrouped_tokens(cls, expr): 310 | for token in cls.__BITVEC_EXPR.findall(expr): 311 | if token.startswith('{'): 312 | rep = int(token[1:-1]) 313 | assert rep >= 1, 'repetition numbers must be >= 1' 314 | yield from repeat(last_token, rep-1) 315 | else: 316 | yield token 317 | last_token = token 318 | 319 | @classmethod 320 | def from_expression(cls, expr: str): 321 | """Generate a partial assignment from a string containing a bit vector 322 | expression. Such an expression consists of a sequence of these tokens: ``0`` 323 | - a constant 0, ``1`` - a constant 1, ``*`` - all combinations of 0 and 1, 324 | ``[i]`` - the same as the bit at index i, ``[!i]`` - the inverse of the bit 325 | at index i. 326 | 327 | The last two tokens are called references, with ``i`` being their pointing 328 | index (counting from 0), where ``i`` refers to the i-th token of the 329 | bitvector expression itself. Note that a ``RuntimeError`` is raised if there 330 | is a circular reference chain. 331 | 332 | If a token is repeated, you can use specify a number of repetitions in 333 | curly braces, e.g., ``*{5}`` is the same as ``*****``. This also works with 334 | references. 335 | 336 | Args: 337 | expr (str): Bit vector expression. 338 | 339 | Returns: 340 | partial_assignment: The partial assignment described by the 341 | expression. 342 | 343 | Examples: 344 | This function is useful for generating arrays of bit vectors 345 | with a prescribed structure. For instance, "all bit vectors 346 | of length 4 that start with 1 and where the last two bits are 347 | the same" can be expressed as 348 | 349 | >>> PA = from_expression('1**[2]') 350 | >>> PA 351 | x0 = 1; x3 = x2 352 | >>> PA.all() 353 | array([[1., 0., 0., 0.], 354 | ... [1., 1., 0., 0.], 355 | ... [1., 0., 1., 1.], 356 | ... [1., 1., 1., 1.]]) 357 | """ 358 | G = nx.DiGraph() 359 | G.add_node('1') 360 | for i, token in enumerate(cls._ungrouped_tokens(expr)): 361 | xi = f'x{i}' 362 | G.add_node(xi) 363 | if token == '*': 364 | continue 365 | if token == '0': 366 | G.add_edge(xi, '1', inverse=True); continue 367 | if token == '1': 368 | G.add_edge(xi, '1', inverse=False); continue 369 | xj = 'x' + token.strip('[!]') 370 | G.add_edge(xi, xj, inverse='!' in token) 371 | return cls(graph=G) 372 | 373 | @classmethod 374 | def infer(cls, X: np.ndarray): 375 | X_ = X.reshape(-1, X.shape[-1]) 376 | N, n = X.shape 377 | assert N > 0, 'At least one example is required!' 378 | S = ['*'] * n 379 | # find constants 380 | col_sum = np.sum(X, axis=0) 381 | for i, s in enumerate(col_sum): 382 | if s == 0: S[i] = '0' 383 | elif s == N: S[i] = '1' 384 | # find correspondences 385 | free = np.asarray([i for i in range(n-1, -1, -1) if S[i]=='*'], dtype=int) 386 | col_eq = (X_[:,np.newaxis,:]==X_[...,np.newaxis]).sum(0) 387 | for i, j in combinations(free, r=2): 388 | if col_eq[i, j] == 0: 389 | S[i] = f'[!{j}]' 390 | elif col_eq[i, j] == N: 391 | S[i] = f'[{j}]' 392 | return cls.from_expression(''.join(S)) 393 | 394 | @classmethod 395 | def simplify_expression(cls, expr: str): 396 | pa = cls.from_expression(expr) 397 | return pa.to_expression() 398 | 399 | @__assert_normalized 400 | def to_matrix(self): 401 | # construct transformation matrix 402 | T = np.zeros((self.size, self.num_free+1)) 403 | one = T.shape[1]-1 # index of constant 1 404 | j = 0 405 | for i in range(self.size): 406 | u = f'x{i}' 407 | try: 408 | _, v, data = next(iter(self.__PAG.edges(u, data=True))) 409 | except StopIteration: 410 | # no outgoing edges -> free variable 411 | T[i, j] = 1.0 412 | j += 1 413 | continue 414 | if v == '1': 415 | if not data['inverse']: 416 | T[i, one] = 1.0 417 | else: 418 | l = int(v[1:]) 419 | if data['inverse']: 420 | T[i, l] = -1.0 421 | T[i, one] = 1.0 422 | else: 423 | T[i, l] = 1.0 424 | return T 425 | 426 | @__assert_normalized 427 | def apply(self, Q: qubo.__class__): 428 | """Apply this partial assignment either to a ``qubo`` instance. The 429 | result is a new, smaller instance where the variables fixed by this 430 | partial assignment are made implicit. E.g., if this assignment 431 | corresponds to the expression ``*1**[0]`` and we apply it to a ``qubo`` 432 | of size 5, the resulting ``qubo`` will have size 3 (which is ``num_free`` 433 | of this assignment), and its variables correspond to the ``*`` in the 434 | expression. 435 | 436 | Args: 437 | expr (str): Bit vector expression. 438 | x (numpy.ndarray): Bit vector or array of bit vectors of shape ``(..., m)`` 439 | where ``m`` is the number of ``*`` tokens in ``expr``. 440 | 441 | Returns: 442 | numpy.ndarray: Bit vector(s) of shape ``(..., n)`` where ``n`` is the 443 | number of tokens in ``expr``. 444 | """ 445 | Q = Q 446 | assert Q.n == self.size, 'Size of partial assignment does not match QUBO size' 447 | T = self.to_matrix() 448 | m = make_upper_triangle(T.T @ Q.m @ T) 449 | # eliminate constant 1 from matrix (last row and column) 450 | offset = m[-1, -1] 451 | return qubo(np.diag(m[:-1, -1]) + m[:-1, :-1]), offset 452 | 453 | 454 | def expand(self, x: np.ndarray): 455 | """Fill the free variables of this partial assignments with bits 456 | provided by ``x``. 457 | 458 | Args: 459 | x (np.ndarray): Bits to fill the free variables of this partial 460 | assignment with. Must have shape ``(m?, n)``, where ``n`` is the 461 | number of free variables of this partial assignment, as given by 462 | the property ``num_free``. 463 | 464 | Returns: 465 | numpy.ndarray: (Array of) bit vector(s) expanded by the partial 466 | assignment. The shape is ``(m?, s)``, where ``s`` is the size 467 | of this partial assignment as given by the property ``size``. 468 | """ 469 | *r, k = x.shape 470 | assert k == self.num_free, 'Dimension of `x` does not match free variables in expression' 471 | z = np.empty((*r, self.size)) 472 | ix = 0 473 | for i, token in enumerate(self._ungrouped_tokens(self.to_expression())): 474 | if token in '01': 475 | z[..., i] = float(token) 476 | elif token.startswith('[!'): 477 | j = int(token[2:-1]) 478 | z[..., i] = 1-z[..., j] 479 | elif token.startswith('['): 480 | j = int(token[1:-1]) 481 | z[..., i] = z[..., j] 482 | else: 483 | z[..., i] = x[..., ix] 484 | ix += 1 485 | return z 486 | 487 | @__assert_normalized 488 | def random(self, size=1, random_state=None): 489 | """Generate random bit vectors matching the constraints emposed by this 490 | bit vector expression. 491 | 492 | Args: 493 | size (int, optional): Number of bit vectors to generate. 494 | random_state (optional): A numerical or lexical seed, or a NumPy random generator. Defaults to None. 495 | 496 | Returns: 497 | numpy.ndarray: ``(m?, n)`` array of bit vectors. If ``size`` is 1, then the output shape is ``(n,)``. 498 | """ 499 | rng = get_random_state(random_state) 500 | rand = rng.random((*to_shape(size), self.num_free))<0.5 501 | return self.expand(rand) 502 | 503 | def all(self): 504 | """Generate all vectors matching this given partial assignment. 505 | 506 | Returns: 507 | numpy.ndarray: Array containing all bit vectors that match this 508 | partial assignment. If ``n`` is the size of this assignment and 509 | ``m`` the number of free variables, the resulting shape will be 510 | ``(2**m, n)``. 511 | """ 512 | m = self.num_free 513 | all_ = np.arange(1< 0 514 | return self.expand(all_) 515 | 516 | def match(self, x: np.ndarray): 517 | """Check if a given bit vector or array of bit vectors matches this 518 | partial assignment, i.e., if it represents a valid realization. 519 | 520 | Args: 521 | x (np.ndarray): Bit vector or array of bit vectors of shape ``(m?, n)``. 522 | 523 | Returns: 524 | Boolean ``np.ndarray`` of shape ``(m?,)`` or single Boolean value 525 | indicating if the bit vectors match this partial assignment. 526 | """ 527 | out_shape = x.shape[:-1] if x.ndim > 1 else (1,) 528 | out = np.ones(out_shape, dtype=bool) 529 | if x.shape[-1] != self.size: 530 | return out & False if x.ndim > 1 else False 531 | for i, token in enumerate(self._ungrouped_tokens(self.to_expression())): 532 | if token == '0': 533 | out &= x[..., i] == 0 534 | elif token == '1': 535 | out &= x[..., i] == 1 536 | elif token != '*': 537 | j = int(token.strip('[!]')) 538 | if '!' in token: # inverse 539 | out &= x[..., i] != x[..., j] 540 | else: 541 | out &= x[..., i] == x[..., j] 542 | return out if x.ndim > 1 else out[0] -------------------------------------------------------------------------------- /qubolite/preprocessing.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | 3 | import igraph as ig 4 | import numpy as np 5 | import portion as P 6 | 7 | from . import qubo 8 | from .bounds import ( 9 | lb_roof_dual, 10 | lb_negative_parameters, 11 | ub_local_descent, 12 | ub_sample) 13 | 14 | from ._heuristics import MatrixOrder, HEURISTICS 15 | from ._misc import get_random_state 16 | from .assignment import partial_assignment 17 | from .solving import solution_t 18 | 19 | ################################################################################ 20 | # Dynamic Range Compression # 21 | ################################################################################ 22 | 23 | 24 | def _get_random_index_pair(matrix_order, npr): 25 | row_indices, column_indices = np.where(np.invert(np.isclose(matrix_order.matrix, 0))) 26 | try: 27 | random_index = npr.integers(row_indices.shape[0]) 28 | i, j = row_indices[random_index], column_indices[random_index] 29 | except ValueError: 30 | i, j = 0, 0 31 | return i, j 32 | 33 | def _compute_change(matrix_order, npr, heuristic=None, decision='heuristic', **bound_params): 34 | if decision == 'random': 35 | i, j = _get_random_index_pair(matrix_order, npr) 36 | change = _compute_index_change(matrix_order, i, j, heuristic=heuristic, **bound_params) 37 | elif decision == 'heuristic': 38 | order_indices = matrix_order.dynamic_range_impact() 39 | indices = matrix_order.to_matrix_indices(order_indices, matrix_order.matrix.shape[0]) 40 | changes = [_compute_index_change(matrix_order, x[0], x[1], 41 | heuristic=heuristic, 42 | **bound_params) for x in indices] 43 | drs = [_dynamic_range_change(x[0], x[1], changes[index], 44 | matrix_order) for index, x in enumerate(indices)] 45 | if np.any(drs): 46 | index = np.argmax(drs) 47 | i, j = indices[index] 48 | change = changes[index] 49 | else: 50 | i, j = _get_random_index_pair(matrix_order, npr) 51 | change = _compute_index_change(matrix_order, i, j, 52 | heuristic=heuristic, 53 | **bound_params) 54 | else: 55 | raise NotImplementedError 56 | return i, j, change 57 | 58 | def _compute_pre_opt_bounds(Q, i, j, **kwargs): 59 | lower_bound = { 60 | 'roof_dual': lb_roof_dual, 61 | 'min_sum': lb_negative_parameters 62 | }[kwargs.get('lower_bound', 'roof_dual')] 63 | upper_bound = { 64 | 'local_descent': ub_local_descent, 65 | 'sample': ub_sample 66 | }[kwargs.get('upper_bound', 'local_descent')] 67 | lower_bound = partial(lower_bound, **kwargs.get('lower_bound_kwargs', {})) 68 | upper_bound = partial(upper_bound, **kwargs.get('upper_bound_kwargs', {})) 69 | change_diff = kwargs.get('change_diff', 1e-08) 70 | Q = qubo(Q) 71 | if i != j: 72 | # Define sub-qubos 73 | Q_00, c_00, _ = Q.clamp({i: 0, j: 0}) 74 | Q_01, c_01, _ = Q.clamp({i: 0, j: 1}) 75 | Q_10, c_10, _ = Q.clamp({i: 1, j: 0}) 76 | Q_11, c_11, _ = Q.clamp({i: 1, j: 1}) 77 | # compute bounds 78 | upper_00 = upper_bound(Q_00) + c_00 79 | upper_01 = upper_bound(Q_01) + c_01 80 | upper_10 = upper_bound(Q_10) + c_10 81 | lower_11 = lower_bound(Q_11) + c_11 82 | upper_or = min(upper_00, upper_01, upper_10) 83 | 84 | lower_00 = lower_bound(Q_00) + c_00 85 | lower_01 = lower_bound(Q_01) + c_01 86 | lower_10 = lower_bound(Q_10) + c_10 87 | upper_11 = upper_bound(Q_11) + c_11 88 | lower_or = min(lower_00, lower_01, lower_10) 89 | 90 | suboptimal = lower_11 > min(upper_00, upper_01, upper_10) 91 | optimal = upper_11 < min(lower_00, lower_01, lower_10) 92 | upper_bound = float('inf') if suboptimal else lower_or - upper_11 - change_diff 93 | lower_bound = -float('inf') if optimal else upper_or - lower_11 + change_diff 94 | else: 95 | # Define sub-qubos 96 | Q_0, c_0, _ = Q.clamp({i: 0}) 97 | Q_1, c_1, _ = Q.clamp({i: 1}) 98 | # Compute bounds 99 | upper_0 = upper_bound(Q_0) + c_0 100 | lower_1 = lower_bound(Q_1) + c_1 101 | 102 | lower_0 = lower_bound(Q_0) + c_0 103 | upper_1 = upper_bound(Q_1) + c_1 104 | suboptimal = lower_1 > upper_0 105 | optimal = upper_1 < lower_0 106 | upper_bound = float("inf") if suboptimal else lower_0 - upper_1 - change_diff 107 | lower_bound = -float("inf") if optimal else upper_0 - lower_1 + change_diff 108 | return lower_bound, upper_bound 109 | 110 | def _compute_pre_opt_bounds_all(Q, **kwargs): 111 | indices = np.triu_indices(Q.shape[0]) 112 | bounds = np.zeros((len(indices[0]), 2)) 113 | for index, index_pair in enumerate(zip(indices[0], indices[1])): 114 | i, j = index_pair[0], index_pair[1] 115 | res = _compute_pre_opt_bounds(Q, i=i, j=j, **kwargs) 116 | if isinstance(res[0], qubo): 117 | print(f'Optimal configuration is found, clamped QUBO should be returned!') 118 | # return res 119 | else: 120 | bounds[index, 0] = res[0] 121 | bounds[index, 1] = res[1] 122 | return bounds 123 | 124 | def _dynamic_range_change(i, j, change, matrix_order): 125 | old_dynamic_range = matrix_order.dynamic_range 126 | matrix = matrix_order.update_entry(i, j, change, True) 127 | new_dynamic_range = qubo(matrix).dynamic_range() 128 | dynamic_range_diff = old_dynamic_range - new_dynamic_range 129 | return dynamic_range_diff 130 | 131 | def _check_to_next_increase(matrix_order, change, i, j): 132 | current_entry = matrix_order.matrix[i, j] 133 | new_entry = current_entry + change 134 | lower_index = np.searchsorted(matrix_order.unique, new_entry, side='right') 135 | lower_entry = matrix_order.unique[lower_index - 1] 136 | min_dis = matrix_order.min_distance 137 | lower_interval = P.open(lower_entry - min_dis, lower_entry + min_dis) 138 | try: 139 | upper_entry = matrix_order.unique[lower_index] 140 | upper_interval = P.open(upper_entry - min_dis, upper_entry + min_dis) 141 | forbidden_interval = lower_interval | upper_interval 142 | except IndexError: 143 | forbidden_interval = lower_interval 144 | possible_interval = P.openclosed(-P.inf, new_entry) 145 | difference = possible_interval.difference(forbidden_interval) 146 | difference = difference | P.singleton(lower_entry) 147 | return difference.upper - current_entry 148 | 149 | def _check_to_next_decrease(matrix_order, change, i, j): 150 | current_entry = matrix_order.matrix[i, j] 151 | new_entry = current_entry + change 152 | upper_index = np.searchsorted(matrix_order.unique, new_entry, side='left') 153 | upper_entry = matrix_order.unique[upper_index] 154 | min_dis = matrix_order.min_distance 155 | upper_interval = P.open(upper_entry - min_dis, upper_entry + min_dis) 156 | try: 157 | lower_entry = matrix_order.unique[upper_index - 1] 158 | lower_interval = P.open(lower_entry - min_dis, lower_entry + min_dis) 159 | forbidden_interval = lower_interval | upper_interval 160 | except IndexError: 161 | forbidden_interval = upper_interval 162 | possible_interval = P.openclosed(new_entry, P.inf) 163 | difference = possible_interval.difference(forbidden_interval) 164 | difference = difference | P.singleton(upper_entry) 165 | return difference.lower - current_entry 166 | 167 | def _compute_index_change(matrix_order, i, j, heuristic=None, **kwargs): 168 | # Decide whether to increase or decrease 169 | increase = heuristic.decide_increase(matrix_order, i, j) 170 | # Bounds on changes based on reducing the dynamic range 171 | dyn_range_change = heuristic.compute_change(matrix_order, i, j, increase) 172 | # Bounds on changes based on preserving the optimum 173 | if increase: 174 | _, pre_opt_change = _compute_pre_opt_bounds(matrix_order.matrix, i, j, **kwargs) 175 | else: 176 | pre_opt_change, _ = _compute_pre_opt_bounds(matrix_order.matrix, i, j, **kwargs) 177 | set_to_zero = heuristic.set_to_zero() 178 | if increase: 179 | change = min(pre_opt_change, dyn_range_change) 180 | if change < 0 or np.isclose(change, 0): 181 | change = 0 182 | elif 0 > matrix_order.matrix[i, j] > - change and set_to_zero: 183 | change = - matrix_order.matrix[i, j] 184 | else: 185 | change = _check_to_next_increase(matrix_order, change, i, j) 186 | else: 187 | change = max(pre_opt_change, dyn_range_change) 188 | if change > 0 or np.isclose(change, 0): 189 | change = 0 190 | elif 0 < matrix_order.matrix[i, j] < - change and set_to_zero: 191 | change = - matrix_order.matrix[i, j] 192 | else: 193 | change = _check_to_next_decrease(matrix_order, change, i, j) 194 | return change 195 | 196 | def reduce_dynamic_range( 197 | Q: qubo, 198 | iterations=100, 199 | heuristic='greedy0', 200 | random_state=None, 201 | decision='heuristic', 202 | callback=None, 203 | **kwargs): 204 | """Iterative procedure for reducing the dynammic range of a given QUBO, while preserving an 205 | optimum, described in `Mücke et al. (2023) `__. 206 | For this, at every step we choose a specific QUBO weight and change it according to 207 | some heuristic. 208 | 209 | Args: 210 | Q (qubolite.qubo): QUBO 211 | iterations (int, optional): Number of iterations. Defaults to 100. 212 | heuristic (str, optional): Used heuristic for computing weight change. Possible heuristics 213 | are 'greedy0', 'greedy' and 'order'. Defaults to 'greedy0'. 214 | random_state (optional): A numerical or lexical seed, or a NumPy random generator. 215 | Defaults to None. 216 | decision (str, optional): Method for deciding which QUBO weight to change next. 217 | Possibilities are 'random' and 'heuristic'. Defaults to 'heuristic'. 218 | callback (optional): Callback function which obtains the following inputs after each step: 219 | i (int), j (int) , change (float), current matrix order (MatrixOrder), current 220 | iteration (int). Defaults to None. 221 | **kwargs (optional): Keyword arguments for determining the upper and lower bound 222 | computations of the optimal QUBO value. 223 | Keyword Args: 224 | change_diff (float): Distance to optimum for avoiding numerical madness. Defaults to 1e-8. 225 | upper_bound (str): Method for upper bound, possibilities are 'local_descent' and 'sample'. 226 | Defaults to 'local_descent'. 227 | lower_bound (str): Method for lower bound, possibilities are 'roof_dual' and 'min_sum'. 228 | Defaults to 'roof_dual'. 229 | upper_bound_kwargs (dict): Additional keyword arguments for upper bound method. 230 | lower_bound_kwargs (dict): Additional keyword arguments for lower bound method. 231 | Returns: 232 | qubolite.qubo: Compressed QUBO with reduced dynamic range. 233 | """ 234 | try: 235 | heuristic = HEURISTICS[heuristic] 236 | except KeyError: 237 | raise ValueError(f'Unknown heuristic "{heuristic}", available are "greedy0", "greedy" and "order"') 238 | npr = get_random_state(random_state) 239 | Q_copy = Q.copy() 240 | matrix_order = MatrixOrder(Q_copy.m) 241 | stop_update = False 242 | matrix_order.matrix = np.round(matrix_order.matrix, decimals=8) 243 | for it in range(iterations): 244 | if not stop_update: 245 | i, j, change = _compute_change(matrix_order, heuristic=heuristic, npr=npr, 246 | decision=decision, **kwargs) 247 | stop_update = matrix_order.update_entry(i, j, change) 248 | if callback is not None: 249 | callback(i, j, change, matrix_order, it) 250 | else: 251 | break 252 | return qubo(matrix_order.matrix) 253 | 254 | 255 | ################################################################################ 256 | # QPRO+ Algorithm # 257 | ################################################################################ 258 | 259 | def _calculate_Dplus_and_Dminus(Q: np.ndarray): 260 | """Calculates a bound for each variable of the possible impact of the variable. 261 | 262 | Args: 263 | Q (np.ndarray): Array that contains the QUBO 264 | 265 | Returns: 266 | np.ndarray: A 2d array, where the first column contains the positive bounds and the second 267 | column the negative bounds 268 | """ 269 | Q_plus = np.multiply(Q, Q > 0) 270 | np.fill_diagonal(Q_plus, 0) 271 | row_sums_plus = np.sum(Q_plus, axis = 0) 272 | coulumn_sums_plus = np.sum(Q_plus, axis = 1) 273 | d_plus = row_sums_plus + coulumn_sums_plus 274 | Q_minus = np.multiply(Q, Q < 0) 275 | np.fill_diagonal(Q_minus, 0) 276 | row_sums_minus = np.sum(Q_minus, axis = 0) 277 | coulumn_sums_minus = np.sum(Q_minus, axis = 1) 278 | d_minus = row_sums_minus + coulumn_sums_minus 279 | return np.vstack((d_minus, d_plus)) 280 | 281 | def _reduceQ(Q: np.ndarray, assignment: tuple, D_list: np.ndarray, indices: list): 282 | """Given an assignment, updates the Q matrix so that the QUBO stays 283 | equivalent but the assigned variable can be removed. 284 | 285 | Args: 286 | Q (np.ndarray): containing the QUBO 287 | assignment (tuple): first element is the variabe index, second elment is the assignment, 288 | i.e. 0 or 1 289 | D_list (np.ndarray): containing bounds as calculated by calculate_Dplus_and_Dminus 290 | 291 | Returns: 292 | np.ndarray: updated Q 293 | np.ndarray: updated D_list 294 | list: updated indices 295 | """ 296 | #does change input D_list 297 | #value is assumed to be either 0 or 1 298 | i = assignment[0] 299 | value = assignment[1] 300 | if value == 1: 301 | Q[indices, indices] += Q[indices, i] + Q[i, indices] 302 | D_list = _D_list_remove(Q, D_list, i) 303 | indices.remove(i) # drop node i 304 | return Q, D_list, indices 305 | 306 | def _D_list_remove(Q: np.ndarray, D_list: np.ndarray, i: int): 307 | """Removes influence of variable i in D_list. Used in reduceQ2_5 and reduceQ2_6 308 | 309 | Args: 310 | Q (np.ndarray): containing the QUBO 311 | D_list (np.ndarray): containing bounds as calculated by calculate_Dplus_and_Dminus 312 | i (int): variable whose values are to be removed 313 | 314 | Returns: 315 | np.ndarray: updated D_list 316 | """ 317 | d_ij = Q[:, i] + Q[i, :] 318 | d_plus = d_ij > 0 319 | d_minus = d_ij < 0 320 | D_list[1, d_plus] -= d_ij[d_plus] 321 | D_list[0, d_minus] -= d_ij[d_minus] 322 | return D_list 323 | 324 | def _D_list_correct_i( 325 | new_i_row_column: np.ndarray, 326 | D_list: np.ndarray, 327 | i: int, 328 | h:int, 329 | indices: list): 330 | """Corrects the entry of the i-th variable in D_list in reduceQ2_5 and reduceQ2_6 331 | 332 | Args: 333 | new_i_row_column (np.ndarray): containing the updated row and column of variable i 334 | D_list (np.ndarray): containing bounds as calculated by calculate_Dplus_and_Dminus 335 | i (int): variable whose value is corrected 336 | h (int): variable that is removed by 2.5 or 2.6 337 | indices (list): of variables that have not been assigned yet 338 | 339 | Returns: 340 | np.ndarray: updated D_list 341 | """ 342 | #add new elements d_hj 343 | new_i_row_column[i] = 0 344 | new_i_row_column[h] = 0 345 | positive = new_i_row_column > 0 346 | negative = new_i_row_column < 0 347 | D_list[1, positive] += new_i_row_column[positive] 348 | D_list[0, negative] += new_i_row_column[negative] 349 | #fix D_i^+ and D_i^- 350 | D_list[1, i] = np.sum(new_i_row_column[indices][positive[indices]]) 351 | D_list[0, i] = np.sum(new_i_row_column[indices][negative[indices]]) 352 | return D_list 353 | 354 | def _reduceQ2_5(Q: np.ndarray, assignment: tuple, D_list: np.ndarray, indices: list): 355 | """ 356 | Implements updates according to rule 2.5, i.e. assumes x_h = 1 - x_i and updates QUBO 357 | accordingly 358 | 359 | Args: 360 | Q (np.ndarray): containing the QUBO 361 | assignment (tuple): first element is the variabe h, second elment is the variable h 362 | D_list (np.ndarray): containing bounds as calculated by calculate_Dplus_and_Dminus 363 | indices (list): of variables that have not been assigned yet 364 | 365 | Returns: 366 | np.ndarray: updated Q 367 | np.ndarray: updated D_list 368 | list: updated indices 369 | """ 370 | #x_h = 1 - x_i 371 | i = assignment[1] 372 | h = assignment[0] 373 | c_i = Q[i,i] 374 | c_h = Q[h,h] 375 | #D_list update 376 | #remove h and i from all calculations 377 | D_list = _D_list_remove(Q, D_list, i) 378 | D_list = _D_list_remove(Q, D_list, h) 379 | new_i_row_column = (Q[:, i] + Q[i, :]) - (Q[:, h] + Q[h, :]) 380 | Q[:i, i] = new_i_row_column[:i] 381 | Q[i, i+1:] = new_i_row_column[i+1:] 382 | Q[indices, indices] += (Q[indices, h] + Q[h, indices]) 383 | Q[i,i] = c_i - c_h 384 | #add new elements d_hj and fix D_i^+ and D_i^- 385 | D_list = _D_list_correct_i(new_i_row_column, D_list, i, h, indices) 386 | #remove variable x_h from matrix by deleting row and column h 387 | indices.remove(h) 388 | return Q, D_list, indices 389 | 390 | def _reduceQ2_6( 391 | Q: np.ndarray, 392 | assignment: tuple, 393 | D_list: np.ndarray, 394 | indices:list): 395 | """ 396 | Implements updates according to rule 2.6, i.e. assumes x_h = x_i and updates QUBO accordingly 397 | 398 | Args; 399 | Q (np.ndarray): containing the QUBO 400 | assignment (tuple): first element is the variabe h, second elment is the variable h 401 | D_list (np.ndarray): containing bounds as calculated by calculate_Dplus_and_Dminus 402 | indices (list): variables that have not been assigned yet 403 | 404 | Returns: 405 | np.ndarray: updated Q 406 | np.ndarray: updated D_list 407 | np.ndarray: updated indices 408 | """ 409 | #x_h = x_i 410 | i = assignment[1] 411 | h = assignment[0] 412 | c_i = Q[i,i] 413 | c_h = Q[h,h] 414 | d_hi = Q[h, i] 415 | #D_list update 416 | #remove h and i from all calculations 417 | D_list = _D_list_remove(Q, D_list, i) 418 | D_list = _D_list_remove(Q, D_list, h) 419 | #calculate new x_i values as d_ij + d_hj 420 | new_i_row_column = (Q[:, i] + Q[i, :]) + (Q[:, h] + Q[h, :]) 421 | Q[:i, i] = new_i_row_column[:i] 422 | Q[i, i+1:] = new_i_row_column[i+1:] 423 | Q[i,i] = c_i + c_h + d_hi 424 | #add new elements d_hj and fix D_i^+ and D_i^- 425 | D_list = _D_list_correct_i(new_i_row_column, D_list, i, h, indices) 426 | #remove variable x_h from matrix by deleting row and column h 427 | indices.remove(h) 428 | return Q, D_list, indices 429 | 430 | def _assign_1( 431 | Qmatrix: np.ndarray, 432 | D_list: np.ndarray, 433 | indices: list, 434 | assignments: dict, 435 | last_assignment: int, 436 | c_0: int, 437 | i: int, 438 | c_i: int): 439 | assignments["x_" + str(i)] = 1 #store what assignment is being made 440 | last_assignment = i 441 | Qmatrix, D_list, indices = _reduceQ(Qmatrix, (i, 1), D_list, indices) 442 | c_0 += c_i 443 | return Qmatrix, D_list, indices, assignments, last_assignment, c_0 444 | 445 | def _assign_0( 446 | Qmatrix: np.ndarray, 447 | D_list: np.ndarray, 448 | indices: list, 449 | assignments: dict, 450 | last_assignment: int, i: int): 451 | assignments["x_" + str(i)] = 0 #store what assignment is beeing made 452 | last_assignment = i 453 | Qmatrix, D_list, indices = _reduceQ(Qmatrix, (i, 0), D_list, indices) 454 | return Qmatrix, D_list, indices, assignments, last_assignment 455 | 456 | def _apply_rule2_5( 457 | Qmatrix: np.ndarray, 458 | D_list: np.ndarray, 459 | indices: list, 460 | assignments: dict, 461 | last_assignment: int, 462 | c_0: int, 463 | i: int, 464 | h: int, 465 | c_h: int): 466 | assignments["x_" + str(i)] = f'[!{h}]' #store what assignment is being made 467 | assignments["x_" + str(h)] = f'[!{i}]' #store what assignment is being made 468 | last_assignment = i+1 469 | Qmatrix, D_list, indices = _reduceQ2_5(Qmatrix, (h, i), D_list, indices) 470 | c_0 += c_h 471 | return Qmatrix, D_list, indices, assignments, last_assignment, c_0 472 | 473 | def _apply_rule2_6( 474 | Qmatrix: np.ndarray, 475 | D_list: np.ndarray, 476 | indices: list, 477 | assignments: dict, 478 | last_assignment: int, 479 | i: int, h: int): 480 | assignments["x_" + str(i)] = f'[{h}]' #store what assignment is being made 481 | assignments["x_" + str(h)] = f'[{i}]' #store what assignment is being made 482 | last_assignment = i+1 483 | Qmatrix, D_list, indices = _reduceQ2_6(Qmatrix, (h, i), D_list, indices) 484 | return Qmatrix, D_list, indices, assignments, last_assignment 485 | 486 | def qpro_plus(Q: qubo): 487 | """Implements the routine applying rules described in 488 | `Glover et al. (2018) `__ 489 | for reducing the QUBO size by applying logical implications. 490 | 491 | Args: 492 | Q (qubo): QUBO instance to be reduced. 493 | 494 | Returns: 495 | Instance of :class:`assignment.partial_assignment` representing the 496 | reduction. See example. 497 | 498 | Example: 499 | >>> import qubolite as ql 500 | >>> Q = ql.qubo.random(32, density=0.2, random_state='example') 501 | >>> PA = ql.preprocessing.qpro_plus(Q) 502 | >>> print(f'{PA.num_fixed} variables were eliminated!') 503 | 9 variables were eliminated! 504 | >>> Q_reduced, offset = PA.apply(Q) 505 | >>> Q_reduced.n 506 | 23 507 | >>> x = ql.bitvec.from_string('10011101011011110001011') 508 | >>> Q_reduced(x)+offset 509 | -0.5215481745331401 510 | >>> Q(PA.expand(x)) 511 | -0.5215481745331385 512 | 513 | """ 514 | #assumes QUBO to be upper triangluar 515 | #paper assumes maximazation hence we need to flip the sign for minimization 516 | m = -Q.m 517 | assignments = dict() # dict of assignments 518 | hList = list() 519 | c_0 = 0 # offset to restore original energy function 520 | #calculate D_i^+ and D_i^- for each variable 521 | D_list = _calculate_Dplus_and_Dminus(m) 522 | last_assignment = 0 #index of the most recent variable that has been assigned 523 | #variables that have not been assigned yet 524 | indices = list(range(Q.n)) 525 | 526 | while (last_assignment != -1): 527 | last_assignment = -1 528 | #apply rules 529 | hList.clear() 530 | for i in indices.copy(): 531 | #apply rule 1.0 and 2.0 532 | c_i = m[i,i] 533 | if c_i + D_list[0, i] >= 0: #rule 1.0 534 | #x_i = 1 535 | m, D_list, indices, _, last_assignment, c_0 = _assign_1( 536 | m, D_list, indices, assignments, last_assignment, c_0, i, c_i) 537 | elif c_i + D_list[1, i] <= 0: #rule 2.0 538 | #x_i = 0 539 | (m, D_list, indices, _, last_assignment) = _assign_0( 540 | m, D_list, indices, assignments, last_assignment, i) 541 | else: 542 | for h in hList: 543 | #define variables: 544 | d_ih = m[h, i] 545 | c_i = m[i, i] 546 | c_h = m[h, h] 547 | if c_h + D_list[0, h] >= 0: #rule 1.0 548 | #x_h = 1 549 | m, D_list, indices, _, last_assignment, c_0 = _assign_1( 550 | m, D_list, indices, assignments, last_assignment, c_0, h, c_h) 551 | # drop node h 552 | hList.remove(h) 553 | 554 | elif c_h + D_list[1, h] <= 0: #rule 2.0 555 | #x_h = 0 556 | m, D_list, indices, _, last_assignment = _assign_0( 557 | m, D_list, indices, assignments, last_assignment, h) 558 | # drop node h 559 | hList.remove(h) 560 | 561 | #rule 3.1 562 | elif d_ih >= 0 and c_i + c_h - d_ih + D_list[1, i] + D_list[1, h] <= 0: 563 | #set x_i = x_h = 0 564 | m, D_list, indices, _, last_assignment = _assign_0( 565 | m, D_list, indices, assignments, last_assignment, i) 566 | break 567 | #rule 3.2 568 | elif d_ih < 0 and -c_i + c_h + d_ih - D_list[0, i] + D_list[1, h] <= 0: 569 | #set x_i = 1, x_h = 0 570 | m, D_list, indices, _, last_assignment, c_0 = _assign_1( 571 | m, D_list, indices, assignments, last_assignment, c_0, i, c_i) 572 | break 573 | #rule 3.3 574 | elif d_ih < 0 and -c_h + c_i + d_ih - D_list[0, h] + D_list[1, i] <= 0: 575 | #set x_i = 0, x_h = 1 576 | m, D_list, indices, _, last_assignment = _assign_0( 577 | m, D_list, indices, assignments, last_assignment, i) 578 | break 579 | #rule 3.4 580 | elif d_ih >= 0 and -c_i - c_h - d_ih - D_list[0, h] - D_list[0, i] <= 0: 581 | #set x_i = 1, x_h = 1 582 | m, D_list, indices, _, last_assignment, c_0 = _assign_1( 583 | m, D_list, indices, assignments, last_assignment, c_0, i, c_i) 584 | break 585 | #rule 2.5 586 | elif (d_ih < 0 and (c_i - d_ih + D_list[0][i] >= 0 587 | or c_h - d_ih + D_list[0][h] >= 0) 588 | and (c_i + d_ih + D_list[1][i] <= 0 589 | or c_h + d_ih + D_list[1][h]) <= 0): 590 | # x_h = 1 - x_i 591 | m, D_list, indices, _, last_assignment, c_0 = _apply_rule2_5( 592 | m, D_list, indices, assignments, last_assignment, c_0, i, h, c_h) 593 | # drop node h 594 | hList.remove(h) 595 | 596 | #rule 2.6 597 | elif (d_ih > 0 and (c_i-d_ih+D_list[1][i] <= 0 598 | or c_h+d_ih+D_list[0][h] >= 0) 599 | and (c_i+d_ih+D_list[0][i] >= 0 600 | or c_h-d_ih+D_list[1][h]) <= 0): 601 | # x_h = x_i 602 | m, D_list, indices, _, last_assignment = _apply_rule2_6( 603 | m, D_list, indices, assignments, last_assignment, i, h) 604 | # drop node h 605 | hList.remove(h) 606 | 607 | if last_assignment != i: # this means x_i could not be assigned 608 | hList.append(i) 609 | 610 | assignment_pattern = ''.join([str(assignments.get(f'x_{i}', '*')) for i in range(Q.n)]) 611 | return partial_assignment.from_expression(assignment_pattern) 612 | # reduced qubo: qubo(-m[np.ix_(indices, indices)]) 613 | # offset: -c_0 614 | 615 | 616 | ################################################################################ 617 | # Graph Transformation # 618 | ################################################################################ 619 | 620 | """ 621 | def try_solve_polynomial(Q: qubo): 622 | if 'quadratic_nonpositive' in Q.properties: 623 | q = np.triu(Q.m, 1) 624 | c = np.diag(Q.m) 625 | s, t = Q.n, Q.n+1 # source and target indices 626 | r = q.sum(0) + q.sum(1) 627 | edges = np.r_[ 628 | np.stack(np.where(~np.isclose(q, 0))).T, # (i, j) 629 | np.c_[np.full(Q.n, s), np.arange(Q.n)], # (s, j) 630 | np.c_[np.arange(Q.n), np.full(Q.n, t)]] # (i, t) 631 | weights = np.r_[ 632 | -q[np.where(~np.isclose(q, 0))], # (i, j) 633 | np.minimum(0, -r-c), # (s, j) 634 | np.minimum(0, r+c)] # (i, t) 635 | #print(f'Q:\n{q}\nc: {c}\nr: {r}\nedges:\n{edges}\nweights:\n{weights}') 636 | G = ig.Graph(n=Q.n+2, edges=edges, edge_attrs={'w': weights}) # note the "-" 637 | cut = G.st_mincut(source=s, target=t, capacity='w') 638 | #print(cut.partition) 639 | ones = cut.partition[0][:-1] # omit s at the end 640 | x = np.zeros(Q.n) 641 | x[ones] = 1 642 | raise NotImplementedError('Not working') 643 | return solution_t(x, Q(x)) # XXX doesn't yield same result as brute force... 644 | return None 645 | """ 646 | -------------------------------------------------------------------------------- /qubolite/qubo.py: -------------------------------------------------------------------------------- 1 | import struct 2 | from functools import cached_property 3 | 4 | import numpy as np 5 | from numpy import newaxis as na 6 | 7 | from .bitvec import all_bitvectors, all_bitvectors_array 8 | from ._misc import ( 9 | deprecated, get_random_state, is_triu, 10 | make_upper_triangle, warn_size) 11 | from _c_utils import brute_force as _brute_force_c 12 | 13 | 14 | def is_qubo_like(arr): 15 | """Check if given array defines a QUBO instance, i.e., if the array is 16 | 2-dimensional and square. 17 | 18 | Args: 19 | arr (numpy.ndarray): Input array. 20 | 21 | Returns: 22 | bool: ``True`` iff the input array defines a QUBO instance. 23 | """ 24 | if arr.ndim == 2: 25 | u, v = arr.shape[-2:] 26 | return u == v 27 | else: 28 | return False 29 | 30 | 31 | def to_triu_form(arr): 32 | """Convert an array defining a QUBO instance to an upper triangle matrix, if 33 | necessary. 34 | 35 | Args: 36 | arr (numpy.ndarray): Input array. 37 | 38 | Returns: 39 | numpy.ndarray: Upper triangular matrix. 40 | """ 41 | if is_triu(arr): 42 | return arr.copy() 43 | else: 44 | # add lower to upper triangle 45 | return make_upper_triangle(arr) 46 | 47 | 48 | def __unwrap_value(obj): 49 | try: 50 | v = obj.m 51 | except AttributeError: 52 | v = obj 53 | return v 54 | 55 | 56 | class qubo: 57 | """ 58 | Standard class for QUBO instances. 59 | This is mainly a wrapper around an upper triangular NumPy matrix with lots 60 | of helpful methods. The passed array must be of the shape ``(n, n)`` for any 61 | positive ``n``. The linear coefficients lie along the diagonal. A 62 | non-triangular matrix will be converted, i.e., the lower triangle will be 63 | transposed and added to the upper triangle. 64 | 65 | Args: 66 | m (np.ndarray): Array containing the QUBO parameters. 67 | 68 | Examples: 69 | If you have linear and quadratic coefficients in separate arrays, e.g., 70 | ``lin`` with shape ``(n,)`` and ``qua`` with shape ``(n, n)``, they can 71 | be combined to a ``qubo`` instance through ``qubo(np.diag(lin) + qua)``. 72 | """ 73 | 74 | def __init__(self, m: np.ndarray): 75 | """ 76 | Creates a ``qubo`` instance from a given NumPy array. 77 | 78 | """ 79 | assert is_qubo_like(m) 80 | self.m = to_triu_form(m) 81 | self.n = m.shape[-1] 82 | 83 | def __repr__(self): 84 | return 'qubo'+self.m.__repr__().lstrip('array') 85 | 86 | def __call__(self, x: np.ndarray): 87 | """Calculate the QUBO energy value for a given bit vector. 88 | 89 | Args: 90 | x (numpy.ndarray): Bit vector of shape ``(n,)``, or multiple bit 91 | vectors ``(m, n)``. 92 | 93 | Returns: 94 | float or numpy.ndarray of shape ``(m,)`` containing energy values. 95 | """ 96 | return np.sum(np.dot(x, self.m)*x, axis=-1) 97 | 98 | def __getitem__(self, k): 99 | try: 100 | i, j = sorted(k) 101 | return self.m.__getitem__((i, j)) 102 | except TypeError: 103 | return self.m.__getitem__((k, k)) 104 | 105 | def __add__(self, other): 106 | return qubo(self.m + __unwrap_value(other)) 107 | 108 | def __sub__(self, other): 109 | return qubo(self.m - __unwrap_value(other)) 110 | 111 | def __mul__(self, other): 112 | return qubo(self.m * __unwrap_value(other)) 113 | 114 | def __truediv__(self, other): 115 | return qubo(self.m / __unwrap_value(other)) 116 | 117 | def copy(self): 118 | """Create a copy of this instance. 119 | 120 | Returns: 121 | qubo: Copy of this QUBO instance. 122 | """ 123 | return qubo(self.m.copy()) 124 | 125 | @classmethod 126 | def random(cls, n: int, 127 | distr='normal', 128 | density=1.0, 129 | full_matrix=False, 130 | random_state=None, 131 | **kwargs): 132 | """Create a QUBO instance with parameters sampled from a random 133 | distribution. 134 | 135 | Args: 136 | n (int): QUBO size 137 | distr (str, optional): Distribution from which the parameters are 138 | sampled. Possible values are ``'normal'``, ``'uniform'`` and 139 | ``'triangular'``. Additional keyword arguments will be passed to 140 | the corresponding methods from ``numpy.random``. Defaults to 141 | ``'normal'``. 142 | density (float, optional): Expected density of the parameter matrix. 143 | Each parameter is set to 0 with probability ``1-density``. 144 | Defaults to 1.0. 145 | full_matrix (bool, optional): Indicate if the full n×n matrix should 146 | be sampled and then folded into upper triangle form, or if the 147 | triangular matrix should be sampled directly. Defaults to ``False``. 148 | random_state (optional): A numerical or lexical seed, or a NumPy 149 | random generator. Defaults to None. 150 | 151 | Raises: 152 | ValueError: Raised if the ``distr`` argument is unknown. 153 | 154 | Returns: 155 | qubo: Random QUBO instance 156 | """ 157 | npr = get_random_state(random_state) 158 | if distr == 'normal': 159 | arr = npr.normal( 160 | kwargs.get('loc', 0.0), 161 | kwargs.get('scale', 1.0), 162 | size=(n, n)) 163 | elif distr == 'uniform': 164 | arr = npr.uniform( 165 | kwargs.get('low', -1.0), 166 | kwargs.get('high', 1.0), 167 | size=(n, n)) 168 | elif distr == 'triangular': 169 | arr = npr.triangular( 170 | kwargs.get('left', -1.0), 171 | kwargs.get('mode', 0.0), 172 | kwargs.get('right', 1.0), 173 | size=(n, n)) 174 | else: 175 | raise ValueError(f'Unknown distribution "{distr}"') 176 | if density < 1.0: 177 | arr *= npr.random(size=arr.shape)>> Q = qubo.random(4, density=0.25).round(1) 263 | >>> Q 264 | qubo([[ 0.6, 0. , 0.5, 0. ], 265 | [ 0. , 0. , -0.4, 0. ], 266 | [ 0. , 0. , 0. , -0.3], 267 | [ 0. , 0. , 0. , 0. ]]) 268 | >>> Q.to_dict() 269 | {(0, 0): 0.6, (0, 2): 0.5, (1, 2): -0.4, (2, 3): -0.3} 270 | """ 271 | if names is None: 272 | names = { i: i for i in range(self.n) } 273 | qubo_dict = dict() 274 | for i, j in zip(*np.triu_indices_from(self.m)): 275 | if not np.isclose(self.m[i, j], 0, atol=atol): 276 | if (i == j) and (not double_indices): 277 | qubo_dict[(names[i],)] = self.m[i, i] 278 | else: 279 | qubo_dict[(names[i], names[j])] = self.m[i, j] 280 | return qubo_dict 281 | 282 | @classmethod 283 | def from_dict(cls, qubo_dict, n=None, relabel=True): 284 | """Create QUBO instance from a dictionary mapping variable indices to 285 | QUBO parameters. Note that, by default, unused variables are eliminated, 286 | e.g., the dictionary ``{(0,): 2, (100,): -3}`` yields a QUBO instance of 287 | size n=2. If you want to use the dictionary keys as variable indices 288 | as-is, set ``relabel=False``. 289 | 290 | Args: 291 | qubo_dict (dict): Dictionary mapping indices to QUBO parameters. 292 | n (int, optional): Specifies QUBO size. If None, the size is derived 293 | from the number of variable names. 294 | relabel (bool, optional): Indicate whether the variables should be 295 | used as indices as-is, instead of removing unused variables. 296 | This works only for integer keys. 297 | 298 | Returns: 299 | qubo: QUBO instance with parameters taken from dictionary. 300 | dict: Dictionary mapping the names of the variables used in the 301 | input dictionary to the indices of the QUBO instance. If 302 | ``relabel=False``, this dictionary will be an identity map. 303 | """ 304 | if relabel: 305 | key_set = set().union(*qubo_dict.keys()) 306 | names = { k: i for i, k in enumerate(sorted(key_set)) } 307 | else: 308 | names = { i: i for i in set().union(*qubo_dict.keys()) } 309 | 310 | n = max(names.values())+1 if n is None else n 311 | m = np.zeros((n, n)) 312 | for k, v in qubo_dict.items(): 313 | try: 314 | i, j = k 315 | m[names[i], names[j]] += v 316 | except ValueError: 317 | try: 318 | i, = k 319 | m[names[i], names[i]] += v 320 | except ValueError: 321 | pass 322 | m = np.triu(m + np.tril(m, -1).T) 323 | return cls(m), { i: k for k, i in names.items() } 324 | 325 | def save_qbsolv(self, path: str, atol=1e-16): 326 | """Save this QUBO instance using the ``.qubo`` file format used by 327 | D-Wave's ``qbsolv`` package. 328 | 329 | Args: 330 | path (str): Target file path. 331 | atol (float, optional): Parameters with absolute value below this 332 | value will be treated as 0. Defaults to 1e-16. 333 | """ 334 | with open(path, 'w') as f: 335 | f.write( 336 | 'c this is a qbsolv-style .qubo file\n' 337 | 'c saved with qubolite (c) Sascha Muecke\n' 338 | f'p qubo 0 {self.n} {self.n} {self.num_couplings}\n' 339 | 'c ' + '-'*30 + '\n') 340 | for i in range(self.n): 341 | if not np.isclose(self.m[i, i], 0, atol=atol): 342 | f.write(f'{i} {i} {self.m[i, i]}\n') 343 | f.write('c ' + '-'*30 + '\n') 344 | for i, j in zip(*np.where(~np.isclose(np.triu(self.m, 1), 0, atol=atol))): 345 | f.write(f'{i} {j} {self.m[i,j]}\n') 346 | 347 | @classmethod 348 | def load_qbsolv(cls, path: str): 349 | """Load a QUBO instance from a file saved in the ``.qubo`` file format 350 | used by D-Wave's ``qbsolv`` package. 351 | 352 | Args: 353 | path (str): QUBO file path. 354 | 355 | Raises: 356 | RuntimeError: Raised if an invalid line is encountered 357 | 358 | Returns: 359 | qubo: QUBO instance loaded from disk. 360 | """ 361 | with open(path, 'r') as f: 362 | for line_number, line in enumerate(f): 363 | if line[0].isdigit(): 364 | i, j, w = line.split() 365 | i, j = sorted([int(i), int(j)]) 366 | m[i, j] = np.float64(w) 367 | elif line.startswith('p'): 368 | *_, n, _ = line.split() 369 | n = int(n) 370 | m = np.zeros((n, n)) 371 | elif line.startswith('c'): 372 | continue # ignore comment 373 | else: 374 | raise RuntimeError(f'Invalid format at line {line_number}') 375 | return cls(m) 376 | 377 | def to_ising(self, offset=0.0): 378 | """Convert this QUBO instance to an Ising model with variables 379 | :math:`\\boldsymbol s\\in\\lbrace -1,+1\\rbrace` instead of 380 | :math:`\\boldsymbol x\\in\\lbrace 0,1\\rbrace`. 381 | 382 | Args: 383 | offset (float, optional): Constant offset value added to the energy. 384 | Defaults to 0.0. 385 | 386 | Returns: 387 | Tuple containing 388 | 389 | - linear coefficients (*external field*) with shape ``(n,)`` 390 | - quadratic coefficients (*interactions*) with shape ``(n, n)`` 391 | - new offset (float) 392 | """ 393 | m_ = self.m + self.m.T 394 | lin = 0.25*m_.sum(0) 395 | qua = 0.25*np.triu(self.m, 1) 396 | c = 0.25*(self.m.sum()+np.diag(self.m).sum())+offset 397 | return lin, qua, c 398 | 399 | @classmethod 400 | def from_ising(cls, linear, quadratic, offset=0.0): 401 | """Create QUBO instance from Ising model parameters. In an Ising model, 402 | the binary variables :math:`\\boldsymbol x\\in\\lbrace 0,1,\\rbrace` are 403 | replaced with *bipolar* variables 404 | :math:`\\boldsymbol s\\in\\lbrace -1,+1\\rbrace`. The two models are 405 | computationally equivalent and can be converted into each other by 406 | variable substitution :math:`\\boldsymbol s\\mapsto 2\\boldsymbol x+1`. 407 | 408 | Args: 409 | linear (list | numpy.ndarray): Linear coefficients, often denoted by 410 | :math:`\\boldsymbol h`; also called *external field* in physics. 411 | quadratic (list | numpy.ndarray): Quadratic coefficients, often 412 | denoted by :math:`\\boldsymbol J`; also called *interactions* in 413 | physics. If ``linear`` has shape ``(n,)``, this array must have 414 | shape ``(n, n)``. 415 | offset (float, optional): Constant offset added to the energy value. 416 | Defaults to ``0.0``. 417 | 418 | Returns: 419 | Tuple containing ``qubo`` instance and a new offset value (float). 420 | """ 421 | lin = np.asarray(linear) 422 | qua = np.asarray(quadratic) 423 | n, = lin.shape 424 | assert qua.shape == (n, n), '`linear` and `quadratic` must have shapes (n,) and (n, n)' 425 | qua_symm = qua + qua.T 426 | qua_symm[np.diag_indices_from(qua)] = 0 427 | m = 2*np.diag(lin-qua_symm.sum(0)) 428 | m += 4*np.triu(qua_symm, 1) 429 | c = qua.sum()-lin.sum()+offset 430 | return cls(m), c 431 | 432 | @property 433 | def num_couplings(self): 434 | """Return the number of non-zero quadratic coefficients of this QUBO 435 | instance. 436 | 437 | Returns: 438 | int: Number of non-zero quadratic coefficients. 439 | """ 440 | return int(self.n**2 - np.isclose(np.triu(self.m, 1), 0).sum()) 441 | 442 | def unique_parameters(self): 443 | """Return the unique parameter values of this QUBO instance. 444 | 445 | Returns: 446 | numpy.ndarray: Array containing the unique parameter values, sorted 447 | in ascending order. 448 | """ 449 | mask = np.triu_indices_from(self.m) 450 | return np.unique(self.m[mask]) 451 | 452 | def spectral_gap(self, return_optimum=False, max_threads=256): 453 | """Calculate the spectral gap of this QUBO instance. Here, this is 454 | defined as the difference between the lowest and second-to lowest QUBO 455 | energy value across all bit vectors. Note that the QUBO instance must be 456 | solved for calculating this value, therefore only QUBOs of sizes up to 457 | about 30 are feasible in practice. 458 | 459 | Args: 460 | return_optimum (bool, optional): If ``True``, returns the minimizing 461 | bit vector of this QUBO instance (which is calculated anyway). 462 | Defaults to False. 463 | max_threads (int): Upper limit for the number of threads created by 464 | the brute-force solver. Defaults to 256. 465 | 466 | Raises: 467 | ValueError: Raised if this QUBO instance is too large to be solved 468 | by brute force on the given system. 469 | 470 | Returns: 471 | sgap (float): Spectral gap. 472 | x (numpy.ndarray, optional): Minimizing bit vector. 473 | """ 474 | warn_size(self.n, limit=25) 475 | try: 476 | x, v0, v1 = _brute_force_c(self.m, max_threads) 477 | except TypeError: 478 | raise ValueError('n is too large to brute-force on this system') 479 | sgap = v1-v0 480 | if return_optimum: 481 | return sgap, x 482 | else: 483 | return sgap 484 | 485 | @deprecated 486 | def clamp(self, partial_assignment: dict): 487 | """Create QUBO instance equivalent to this but with a subset of 488 | variables fixed (_clamped_) to constant values. 489 | **Warning:** This method is deprecated. Use 490 | :meth:`assignment.partial_assignment.apply` instead! 491 | 492 | Args: 493 | partial_assignment (dict, optional): Dictionary mapping variable 494 | indices (counting from 0) to constant values 0 or 1. Defaults to 495 | None, which does nothing and returns a copy of this QUBO 496 | instance. 497 | 498 | Returns: 499 | qubo: Clamped QUBO instance. 500 | const (float): Constant offset value, which must be added to the 501 | QUBO energy to obtain the original energy. 502 | free (list): List of indices which the variable indices of the new 503 | QUBO instance correspond to (i.e., those indices that were not 504 | clamped). 505 | """ 506 | if partial_assignment is None: 507 | return self.copy(), 0, set(range(self.n)) 508 | ones = list(sorted({i for i, b in partial_assignment.items() if b == 1})) 509 | free = list(sorted(set(range(self.n)).difference(partial_assignment.keys()))) 510 | R = self.m.copy() 511 | const = R[ones, :][:, ones].sum() 512 | for i in free: 513 | R[i, i] += sum(R[l, i] if l`__ for details. 608 | 609 | Returns: 610 | float: Mean of the QUBO's energy values. 611 | Assumes solutions are sampled from a uniform distribution. 612 | """ 613 | return (self.m.sum() / 4) + (self.m.diagonal().sum() / 4) 614 | 615 | def variance(self): 616 | """Compute variance of :math:`\\boldsymbol x^{\\top}Q\\boldsymbol x`. 617 | Assumes :math:`\\boldsymbol x` is sampled from a uniform distribution. 618 | See `[1] `__ for details. 619 | 620 | Returns: 621 | float: Variance of the QUBO's energy values. 622 | Assumes solutions are sampled from a uniform distribution. 623 | """ 624 | v = 0 625 | m2 =self.m**2 626 | # sum over Q[i,j] * (Q[i,j] + Q[j,i]) 627 | v += m2.sum() - m2.diagonal().sum() 628 | col_sums =self.m.sum(axis=0) 629 | row_sums =self.m.sum(axis=1) 630 | # sum over Q[i,j] * Q[k,i] 631 | v += np.sum(row_sums * col_sums) 632 | # sum over Q[i,j] * Q[i,k] 633 | v += np.sum(row_sums * row_sums) 634 | # sum over Q[i,j] * Q[k,j] 635 | v += np.sum(col_sums * col_sums) 636 | # sum over Q[i,j] * Q[j,k] 637 | v += np.sum(col_sums * row_sums) 638 | return v / 16 639 | 640 | def as_int(self, bits=32): 641 | """Scales and rounds the QUBO parameters to fit a given number of bits. 642 | The number format is assumed to be signed integer, therefore ``b`` bits 643 | yields a value range of ``-2**(b-1)`` to ``2**(b-1)-1``. 644 | 645 | Args: 646 | bits (int, optional): Number of bits to represent the parameters. 647 | Defaults to 32. 648 | 649 | Returns: 650 | qubo: QUBO instance with scaled and rounded parameters. 651 | """ 652 | p_min, p_max = self.m.min(), self.m.max() 653 | if np.abs(p_min) < np.abs(p_max): 654 | factor = ((2**(bits-1))-1)/np.abs(p_max) 655 | else: 656 | factor = (2**(bits-1))/np.abs(p_min) 657 | return qubo((self.m*factor).round()) 658 | 659 | def partition_function(self, log=False, temp=1.0, fast=True): 660 | """Calculate the partition function of the Ising model induced by this 661 | QUBO instance. That is, return the sum of ``exp(-Q(x)/temp)`` over all 662 | bit vectors ``x``. Note that this is infeasibly slow for QUBO sizes much 663 | larger than 20. 664 | 665 | Args: 666 | log (bool, optional): Return the natural log of the partition 667 | function instead. Defaults to False. 668 | temp (float, optional): Temperature parameter of the Gibbs 669 | distribution. Defaults to 1.0. 670 | fast (bool, optional): Internally create array of all bit vectors. 671 | This is faster, but requiers memory space exponential in the 672 | QUBO size. Defaults to True. 673 | 674 | Returns: 675 | float: Value of the partition function, or the log partition 676 | function if ``log=True``. 677 | """ 678 | Z = self.probabilities(temp=temp, unnormalized=True, fast=fast).sum() 679 | return np.log(Z) if log else Z 680 | 681 | def probabilities(self, temp=1.0, out=None, unnormalized=False, fast=True): 682 | """Compute the complete vector of probabilities for observing a vector 683 | ``x`` under the Gibbs distribution induced by this QUBO instance. The 684 | entries of the resulting array are sorted in lexicographic order by bit 685 | vector, e.g. for size 3: ``[000, 100, 010, 110, 001, 101, 011, 111]``. 686 | Note that this method requires memory space exponential in QUBO size, 687 | which quickly becomes infeasible, depending on your system. If ``n`` is 688 | the QUBO size, the output will have size ``2**n``. 689 | 690 | Args: 691 | temp (float, optional): Temperature parameter of the Gibbs 692 | distribution. Defaults to 1.0. 693 | out (numpy.ndarray, optional): Array to write the probabilities to. 694 | Defaults to None, which creates a new array. 695 | unnormalized (bool, optional): Return the unnormalized 696 | probabilities. Defaults to False. 697 | fast (bool, optional): Internally create array of all bit vectors. 698 | This is faster, but requiers memory space exponential in the 699 | QUBO size. Defaults to True. 700 | 701 | Returns: 702 | numpy.ndarray: Array containing probabilities. 703 | """ 704 | if out is None: 705 | out = np.empty(1<`__. 749 | The result is a tuple of an array ``P`` of shape ``(2, n, n)``, where 750 | ``n`` is the QUBO size, and a constant offset value. All entries of the 751 | array are positive. ``P[0]`` contains the coefficients for the literals 752 | ``Xi*Xj``, and ``Xi`` on the diagonal, while ``P[1]`` contains the 753 | coefficients for ``Xi*!Xj`` (``!`` denoting negation), and ``!Xi`` on 754 | the diagonal. See the paper for further infos. 755 | 756 | Returns: 757 | numpy.ndarray: Posiform coefficients (see above) 758 | float: Constant offset value 759 | """ 760 | posiform = np.zeros((2, self.n, self.n)) 761 | # posiform[0] contains terms xi* xj, and xi on diagonal 762 | # posiform[1] contains terms xi*!xj, and !xi on diagonal 763 | lin = np.diag(self.m) 764 | qua = np.triu(self.m, 1) 765 | diag_ix = np.diag_indices_from(self.m) 766 | qua_neg = np.minimum(qua, 0) 767 | posiform[0] = np.maximum(qua, 0) 768 | posiform[1] = -qua_neg 769 | posiform[0][diag_ix] = lin + qua_neg.sum(1) 770 | lin_ = posiform[0][diag_ix].copy() # =: c' 771 | lin_neg = np.minimum(lin_, 0) 772 | posiform[1][diag_ix] = -lin_neg 773 | posiform[0][diag_ix] = np.maximum(lin_, 0) 774 | const = lin_neg.sum() 775 | return posiform, const 776 | 777 | def support_graph(self): 778 | """Return this QUBO instance's support graph. Its nodes are the set of 779 | binary variables, and there is an edge between every pair of variables 780 | that has a non-zero parameter. 781 | 782 | Returns: 783 | _type_: _description_ 784 | """ 785 | nodes = list(range(self.n)) 786 | edges = list(zip(np.where(~np.isclose(np.triu(self.m,1), 0)))) 787 | return nodes, edges 788 | 789 | @cached_property 790 | def properties(self): 791 | props = set() 792 | lin = np.diag(self.m) 793 | qua = np.triu(self.m, 1) 794 | for x, name in [(lin, 'linear'), (qua, 'quadratic')]: 795 | if np.all(x > 0): props.add(f'{name}_positive') 796 | elif np.all(x >= 0): props.add(f'{name}_nonnegative') 797 | if np.all(x < 0): props.add(f'{name}_negative') 798 | elif np.all(x <= 0): props.add(f'{name}_nonpositive') 799 | if np.all(~np.isclose(x, 0)): props.add(f'{name}_nonzero') 800 | 801 | # do some meta checks 802 | and_checks = [ 803 | (['linear_nonnegative', 'linear_nonpositive'], 'linear_zero'), 804 | (['quadratic_nonnegative', 'quadratic_nonpositive'], 'quadratic_zero')] 805 | for ps, p in and_checks: 806 | if all(p_ in props for p_ in ps): props.add(p) 807 | return props 808 | 809 | 810 | def ordering_distance(Q1, Q2, X=None): 811 | try: 812 | from scipy.stats import kendalltau 813 | except ImportError as e: 814 | raise ImportError( 815 | "scipy needs to be installed prior to running qubolite.ordering_distance(). You " 816 | "can install scipy with:\n'pip install scipy'" 817 | ) from e 818 | assert Q1.n == Q2.n, 'QUBO instances must have the same dimension' 819 | warn_size(Q1.n, limit=22) 820 | if X is None: 821 | X = all_bitvectors_array(Q1.n) 822 | rnk1 = np.argsort(np.argsort(Q1(X))) 823 | rnk2 = np.argsort(np.argsort(Q2(X))) 824 | tau, _ = kendalltau(rnk1, rnk2) 825 | return (1-tau)/2 826 | --------------------------------------------------------------------------------