├── 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 |
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 |
--------------------------------------------------------------------------------