diff --git a/utils/adapt-aqc b/utils/adapt-aqc deleted file mode 160000 index da9bf5895b1b694b167f7eaecae358b670ea29d9..0000000000000000000000000000000000000000 --- a/utils/adapt-aqc +++ /dev/null @@ -1 +0,0 @@ -Subproject commit da9bf5895b1b694b167f7eaecae358b670ea29d9 diff --git a/utils/adapt-aqc/.gitignore b/utils/adapt-aqc/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..92abc4a0e79fa2385add865f2655b4aca9467f34 --- /dev/null +++ b/utils/adapt-aqc/.gitignore @@ -0,0 +1,364 @@ +/**/__pycache__/* +.vscode/ +.idea* +*.egg-info/ +======= +# Created by https://www.toptal.com/developers/gitignore/api/python,pycharm,visualstudiocode,osx,macos +# Edit at https://www.toptal.com/developers/gitignore?templates=python,pycharm,visualstudiocode,osx,macos + +## Misc ## + +*.npy +*.pdf +*.png +.idea + +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### macOS Patch ### +# iCloud generated files +*.icloud + +### OSX ### +# General + +# Icon must end with two \r + +# Thumbnails + +# Files that might appear in the root of a volume + +# Directories potentially created on remote AFP share + +### PyCharm ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# AWS User-specific +.idea/**/aws.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# SonarLint plugin +.idea/sonarlint/ + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### PyCharm Patch ### +# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 + +# *.iml +# modules.xml +# .idea/misc.xml +# *.ipr + +# Sonarlint plugin +# https://plugins.jetbrains.com/plugin/7973-sonarlint +.idea/**/sonarlint/ + +# SonarQube Plugin +# https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin +.idea/**/sonarIssues.xml + +# Markdown Navigator plugin +# https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced +.idea/**/markdown-navigator.xml +.idea/**/markdown-navigator-enh.xml +.idea/**/markdown-navigator/ + +# Cache file creation bug +# See https://youtrack.jetbrains.com/issue/JBR-2257 +.idea/$CACHE_FILE$ + +# CodeStream plugin +# https://plugins.jetbrains.com/plugin/12206-codestream +.idea/codestream.xml + +# Azure Toolkit for IntelliJ plugin +# https://plugins.jetbrains.com/plugin/8053-azure-toolkit-for-intellij +.idea/**/azureSettings.xml + +### Python ### +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +### Python Patch ### +# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration +poetry.toml + +# ruff +.ruff_cache/ + +# LSP config files +pyrightconfig.json + +### VisualStudioCode ### +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets + +# Local History for Visual Studio Code +.history/ + +# Built Visual Studio Code Extensions +*.vsix + +### VisualStudioCode Patch ### +# Ignore all local history of files +.history +.ionide + +# End of https://www.toptal.com/developers/gitignore/api/python,pycharm,visualstudiocode,osx,macos diff --git a/utils/adapt-aqc/LICENSE b/utils/adapt-aqc/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..9b95a9fc777491af9e6cd3db94c25f2295cf8a36 --- /dev/null +++ b/utils/adapt-aqc/LICENSE @@ -0,0 +1,203 @@ + Copyright 2025 IBM and its contributors + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2017 IBM and its contributors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/utils/adapt-aqc/README.md b/utils/adapt-aqc/README.md new file mode 100644 index 0000000000000000000000000000000000000000..e2e5d31f2d7ba0dcae70896c3121417dd34e579a --- /dev/null +++ b/utils/adapt-aqc/README.md @@ -0,0 +1,111 @@ +# Adaptive approximate quantum compiling + +An open-source implementation of ADAPT-AQC [1], an approximate quantum compiling (AQC) and matrix +product state (MPS) preparation algorithm. +As opposed to assuming any particular ansatz structure, ADAPT-AQC adaptively builds +an ansatz, adding a new two-qubit unitary every iteration. + +ADAPT-AQC is the successor to ISL [2], using much of the same core code, routine and optimiser. The +most significant difference +however is its use of MPS simulators. This allows it to compile circuits at 50+ qubits, as well as +directly prepare MPSs. + +[1] https://arxiv.org/abs/2503.09683 \ +[2] https://github.com/abhishekagarwal2301/isl + +## Installation + +This repository can be easily installed using `pip`. You have two options: + +Use a stable version based on the last commit to `master` + +``` +pip install git+ssh://git@github.com/qiskit-community/adapt-aqc.git +``` + +Use an editable local version (after already cloning this repository) + +``` +pip install -e PATH_TO_LOCAL_CLONE +``` + +## Contributing + +To make changes to ADAPT-AQC, first clone the repository. +Then navigate to your local copy, create a Python environment and install the required dependencies + +``` +pip install . +``` + +You can check your development environment is ready by successfully running the scripts +in `/examples/`. + +## Minimal examples + +### Compiling with statevector simulator + +A circuit can be compiled and the result accessed with only 3 lines if using the +default settings. + +```python +from adaptaqc.compilers import AdaptCompiler +from qiskit import QuantumCircuit + +# Setup the circuit +qc = QuantumCircuit(3) +qc.rx(1.23, 0) +qc.cx(0, 1) +qc.ry(2.5, 1) +qc.rx(-1.6, 2) +qc.ccx(2, 1, 0) + +# Compile +compiler = AdaptCompiler(qc) +result = compiler.compile() +compiled_circuit = result.circuit + +# See the compiled output +print(compiled_circuit) +``` + +### Compiling matrix product states + +Circuits beyond the size accessible to statevector simulators can be compiled via their +representation as matrix product states. To give a very simple example where most the qubits are +left in the $|0\rangle$ state. + +```python +from qiskit import QuantumCircuit + +from adaptaqc.backends.aer_mps_backend import AerMPSBackend +from adaptaqc.compilers import AdaptCompiler + +n = 50 +qc = QuantumCircuit(n) +qc.h(0) +qc.cx(0, 1) +qc.h(2) +qc.cx(2, 3) +qc.h(range(4, n)) + +# Create compiler with the default MPS simulator, which has very minimal truncation. +adapt_compiler = AdaptCompiler(qc, backend=AerMPSBackend()) + +result = adapt_compiler.compile() +print(f"Overlap between circuits is {result.overlap}") +``` + +### Specifying additional configuration + +For more advanced examples, please see `examples/advanced_mps_example.py` and +`advanced_sv_example.py`. +For a full overview of the different configuration options, in addition to the documentation, see +`docs/running_options_explained.md`. + +## Citing usage + +We respectfully ask any publication, project or whitepaper using ADAPT-AQC to cite the following +work: + +[Jaderberg, B., Pennington, G., Marshall, K.V., Anderson, L.W., Agarwal, A., Lindoy, L.P., Rungger, I., Mensa, S. and Crain, J., 2025. Variational preparation of normal matrix product states on quantum computers. arXiv preprint arXiv:2503.09683](https://arxiv.org/abs/2503.09683) \ No newline at end of file diff --git a/utils/adapt-aqc/adaptaqc/__init__.py b/utils/adapt-aqc/adaptaqc/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..8342b6303ce12adf243f9caeae1ceb3f321188c4 --- /dev/null +++ b/utils/adapt-aqc/adaptaqc/__init__.py @@ -0,0 +1,10 @@ +# (C) Copyright IBM 2025. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + diff --git a/utils/adapt-aqc/adaptaqc/backends/__init__.py b/utils/adapt-aqc/adaptaqc/backends/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..8342b6303ce12adf243f9caeae1ceb3f321188c4 --- /dev/null +++ b/utils/adapt-aqc/adaptaqc/backends/__init__.py @@ -0,0 +1,10 @@ +# (C) Copyright IBM 2025. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + diff --git a/utils/adapt-aqc/adaptaqc/backends/aer_mps_backend.py b/utils/adapt-aqc/adaptaqc/backends/aer_mps_backend.py new file mode 100644 index 0000000000000000000000000000000000000000..082a6cd5be4cfea3352a3b46836fd3cfbb6a0d18 --- /dev/null +++ b/utils/adapt-aqc/adaptaqc/backends/aer_mps_backend.py @@ -0,0 +1,93 @@ +# (C) Copyright IBM 2025. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +import logging + +import numpy as np +from aqc_research.mps_operations import ( + mps_from_circuit, + mps_dot, + mps_expectation, + extract_amplitude, +) +from qiskit_aer import AerSimulator + +from adaptaqc.backends.aqc_backend import AQCBackend + +logger = logging.getLogger(__name__) + + +def mps_sim_with_args(mps_truncation_threshold=1e-16, max_chi=None, mps_log_data=False): + """ + :param mps_truncation_threshold: truncation threshold to use in AerSimulator + :param max_chi: maximum bond dimension to use in AerSimulator + :param mps_log_data: same as corresponding argument in AerSimulator. Setting to true will + massively reduce performance and should only be done for debugging + + :return: instance of AerSimulator using MPS method and parameters specified above + """ + logger.info(f"Using Aer MPS Simulator with truncation {mps_truncation_threshold}") + return AerSimulator( + method="matrix_product_state", + matrix_product_state_truncation_threshold=mps_truncation_threshold, + matrix_product_state_max_bond_dimension=max_chi, + mps_log_data=mps_log_data, + ) + + +class AerMPSBackend(AQCBackend): + def __init__(self, simulator=mps_sim_with_args()): + self.simulator = simulator + + def evaluate_global_cost(self, compiler): + circ_mps = self.evaluate_circuit(compiler) + global_cost = ( + 1 + - np.absolute( + mps_dot(circ_mps, compiler.zero_mps, already_preprocessed=True) + ) + ** 2 + ) + if not compiler.soften_global_cost: + return global_cost + else: + previous_cost = ( + compiler.global_cost_history[-1] + if len(compiler.global_cost_history) > 0 + else 1 + ) + alpha = abs(previous_cost - compiler.adapt_config.sufficient_cost) + hamming_weight_one_overlaps = self.evaluate_hamming_weight_one_overlaps( + circ_mps + ) + return global_cost - alpha * sum(hamming_weight_one_overlaps) + + def evaluate_local_cost(self, compiler): + evals = self.measure_qubit_expectation_values(compiler) + return 0.5 * (1 - np.mean(evals)) + + def evaluate_circuit(self, compiler): + circ = compiler.full_circuit.copy() + return mps_from_circuit(circ, return_preprocessed=True, sim=self.simulator) + + def measure_qubit_expectation_values(self, compiler): + mps = self.evaluate_circuit(compiler) + expectation_values = [ + (mps_expectation(mps, "Z", i, already_preprocessed=True)) + for i in range(compiler.full_circuit.num_qubits) + ] + return expectation_values + + def evaluate_hamming_weight_one_overlaps(self, mps): + overlaps = [ + abs(extract_amplitude(mps, 2**i, already_preprocessed=True)) ** 2 + for i in range(len(mps)) + ] + return overlaps diff --git a/utils/adapt-aqc/adaptaqc/backends/aer_sv_backend.py b/utils/adapt-aqc/adaptaqc/backends/aer_sv_backend.py new file mode 100644 index 0000000000000000000000000000000000000000..da7abfe86ce0d9ccfa0d23b5582a4b7ebf160b1f --- /dev/null +++ b/utils/adapt-aqc/adaptaqc/backends/aer_sv_backend.py @@ -0,0 +1,59 @@ +# (C) Copyright IBM 2025. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +import os + +import numpy as np +from qiskit_aer import Aer + +from adaptaqc.backends.aqc_backend import AQCBackend + + +class AerSVBackend(AQCBackend): + def __init__(self, simulator=Aer.get_backend("statevector_simulator")): + self.simulator = simulator + + def evaluate_global_cost(self, compiler): + if compiler.soften_global_cost: + raise NotImplementedError( + "soften_global_cost is currently only implemented for AerMPSBackend" + ) + sv = self.evaluate_circuit(compiler) + cost = 1 - (np.absolute(sv[0])) ** 2 + return cost + + def evaluate_local_cost(self, compiler): + e_vals = self.measure_qubit_expectation_values(compiler) + cost = 0.5 * (1 - np.mean(e_vals)) + return cost + + def evaluate_circuit(self, compiler): + # Don't parallelise shots if ADAPT-AQC is already being run in parallel + already_in_parallel = os.environ["QISKIT_IN_PARALLEL"] == "TRUE" + backend_options = {} if already_in_parallel else compiler.backend_options + + job = self.simulator.run( + compiler.full_circuit, **backend_options, **compiler.execute_kwargs + ) + + result = job.result() + return result.get_statevector() + + def measure_qubit_expectation_values(self, compiler): + sv = self.evaluate_circuit(compiler) + expectation_values = [] + n_qubits = sv.num_qubits + for i in range(n_qubits): + if i >= n_qubits: + raise ValueError("qubit_index outside of register range") + [p0, p1] = sv.probabilities([i]) + exp_val = p0 - p1 + expectation_values.append(exp_val) + return expectation_values diff --git a/utils/adapt-aqc/adaptaqc/backends/aqc_backend.py b/utils/adapt-aqc/adaptaqc/backends/aqc_backend.py new file mode 100644 index 0000000000000000000000000000000000000000..72ef66dbf1aaf904c83cfaa02f7adce5bc987f47 --- /dev/null +++ b/utils/adapt-aqc/adaptaqc/backends/aqc_backend.py @@ -0,0 +1,29 @@ +# (C) Copyright IBM 2025. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +import abc + + +class AQCBackend(abc.ABC): + @abc.abstractmethod + def evaluate_global_cost(self, compiler): + pass + + @abc.abstractmethod + def evaluate_local_cost(self, compiler): + pass + + @abc.abstractmethod + def evaluate_circuit(self, compiler): + pass + + @abc.abstractmethod + def measure_qubit_expectation_values(self, compiler): + pass diff --git a/utils/adapt-aqc/adaptaqc/backends/itensor_backend.py b/utils/adapt-aqc/adaptaqc/backends/itensor_backend.py new file mode 100644 index 0000000000000000000000000000000000000000..caf2984bef61312a271a6c3c08ed20119517c562 --- /dev/null +++ b/utils/adapt-aqc/adaptaqc/backends/itensor_backend.py @@ -0,0 +1,62 @@ +# (C) Copyright IBM 2025. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +import logging + +from adaptaqc.backends.aqc_backend import AQCBackend +from adaptaqc.utils.circuit_operations import extract_inner_circuit + + +class ITensorBackend(AQCBackend): + def __init__(self, chi=10_000, cutoff=1e-14): + from itensornetworks_qiskit.utils import qiskit_circ_to_it_circ + + self.qiskit_circ_to_it_circ = qiskit_circ_to_it_circ + try: + from juliacall import Main as jl + from juliacall import JuliaError + + self.jl = jl + self.jl.seval("using ITensorNetworksQiskit") + except JuliaError as e: + logging.error("ITensor backend installation not found") + raise e + self.chi = chi + self.cutoff = cutoff + + def evaluate_global_cost(self, compiler): + if compiler.soften_global_cost: + raise NotImplementedError( + "soften_global_cost is currently only implemented for AerMPSBackend" + ) + psi = self.evaluate_circuit(compiler) + + n = compiler.total_num_qubits + return 1 - self.jl.overlap_with_zero_itensors(n, psi, compiler.itensor_sites) + + def evaluate_local_cost(self, compiler): + raise NotImplementedError() + + def evaluate_circuit(self, compiler): + ansatz_circ = extract_inner_circuit( + compiler.full_circuit, compiler.ansatz_range() + ) + gates = self.qiskit_circ_to_it_circ(ansatz_circ) + psi = self.jl.mps_from_circuit_and_mps_itensors( + compiler.itensor_target, + gates, + self.chi, + self.cutoff, + compiler.itensor_sites, + ) + return psi + + def measure_qubit_expectation_values(self, compiler): + raise NotImplementedError() diff --git a/utils/adapt-aqc/adaptaqc/backends/julia_default_backends.py b/utils/adapt-aqc/adaptaqc/backends/julia_default_backends.py new file mode 100644 index 0000000000000000000000000000000000000000..c0dd6dd36dfd9c315a1982baa4139065df15eeff --- /dev/null +++ b/utils/adapt-aqc/adaptaqc/backends/julia_default_backends.py @@ -0,0 +1,13 @@ +# (C) Copyright IBM 2025. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +from adaptaqc.backends.itensor_backend import ITensorBackend + +ITENSOR_SIM = ITensorBackend() diff --git a/utils/adapt-aqc/adaptaqc/backends/python_default_backends.py b/utils/adapt-aqc/adaptaqc/backends/python_default_backends.py new file mode 100644 index 0000000000000000000000000000000000000000..a66e48dd4de7c74881597218d4302397fc4c0951 --- /dev/null +++ b/utils/adapt-aqc/adaptaqc/backends/python_default_backends.py @@ -0,0 +1,19 @@ +# (C) Copyright IBM 2025. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +from adaptaqc.backends.aer_mps_backend import AerMPSBackend +from adaptaqc.backends.aer_sv_backend import AerSVBackend +from adaptaqc.backends.qiskit_sampling_backend import QiskitSamplingBackend + +# These constants are generally used in testing and are a relic from before we had custom classes +# for each type of backend. +QASM_SIM = QiskitSamplingBackend() +SV_SIM = AerSVBackend() +MPS_SIM = AerMPSBackend() diff --git a/utils/adapt-aqc/adaptaqc/backends/qiskit_sampling_backend.py b/utils/adapt-aqc/adaptaqc/backends/qiskit_sampling_backend.py new file mode 100644 index 0000000000000000000000000000000000000000..aaa9f0cf4efd4326fd81135c0f8f05f987553152 --- /dev/null +++ b/utils/adapt-aqc/adaptaqc/backends/qiskit_sampling_backend.py @@ -0,0 +1,108 @@ +# (C) Copyright IBM 2025. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +import os + +import numpy as np +from qiskit_aer import Aer +from qiskit_aer.backends.aerbackend import AerBackend + +from adaptaqc.backends.aqc_backend import AQCBackend + + +class QiskitSamplingBackend(AQCBackend): + def __init__(self, simulator=Aer.get_backend("qasm_simulator")): + self.simulator = simulator + + def evaluate_global_cost(self, compiler): + if compiler.soften_global_cost: + raise NotImplementedError( + "soften_global_cost is currently only implemented for AerMPSBackend" + ) + counts = self.evaluate_circuit(compiler) + total_qubits = ( + 2 * compiler.total_num_qubits + if compiler.general_initial_state + else compiler.total_num_qubits + ) + all_zero_string = "".join(str(int(e)) for e in np.zeros(total_qubits)) + total_shots = sum([each_count for _, each_count in counts.items()]) + # '00...00' might not be present in counts if no shot resulted in + # the ground state + if all_zero_string in counts: + overlap = counts[all_zero_string] / total_shots + else: + overlap = 0 + cost = 1 - overlap + return cost + + def evaluate_local_cost(self, compiler): + qubit_costs = np.zeros(compiler.total_num_qubits) + for i in range(compiler.total_num_qubits): + if compiler.general_initial_state: + compiler.full_circuit.measure(i, 0) + compiler.full_circuit.measure(i + compiler.total_num_qubits, 1) + counts = self.evaluate_circuit(compiler) + del compiler.full_circuit.data[-1] + del compiler.full_circuit.data[-1] + total_shots = sum([each_count for _, each_count in counts.items()]) + # '00...00' might not be present in counts if no shot + # resulted in the ground state + if "00" in counts: + overlap = counts["00"] / total_shots + else: + overlap = 0 + qubit_costs[i] = 1 - overlap + else: + compiler.full_circuit.measure(i, 0) + counts = self.evaluate_circuit(compiler) + del compiler.full_circuit.data[-1] + total_shots = sum([each_count for _, each_count in counts.items()]) + # '00...00' might not be present in counts if no shot + # resulted in the ground state + if "0" in counts: + overlap = counts["0"] / total_shots + else: + overlap = 0 + qubit_costs[i] = 1 - overlap + cost = np.mean(qubit_costs) + return cost + + def evaluate_circuit(self, compiler): + # Don't parallelise shots if ADAPT-AQC is already being run in parallel + already_in_parallel = os.environ["QISKIT_IN_PARALLEL"] == "TRUE" + backend_options = None if already_in_parallel else compiler.backend_options + + if backend_options is None or not isinstance(self.simulator, AerBackend): + backend_options = {} + job = self.simulator.run( + compiler.full_circuit, **backend_options, **compiler.execute_kwargs + ) + result = job.result() + return result.get_counts() + + def measure_qubit_expectation_values(self, compiler): + counts = self.evaluate_circuit(compiler) + n_qubits = len(list(counts)[0]) + + expectation_values = [] + for i in range(n_qubits): + if i >= n_qubits: + raise ValueError("qubit_index outside of register range") + reverse_index = n_qubits - (i + 1) + exp_val = 0 + total_counts = 0 + for bitstring in list(counts): + exp_val += (1 if bitstring[reverse_index] == "0" else -1) * counts[ + bitstring + ] + total_counts += counts[bitstring] + expectation_values.append(exp_val / total_counts) + return expectation_values diff --git a/utils/adapt-aqc/adaptaqc/compilers/__init__.py b/utils/adapt-aqc/adaptaqc/compilers/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..23be09355a6be2bfaa321b0c7b4b0c03622053d7 --- /dev/null +++ b/utils/adapt-aqc/adaptaqc/compilers/__init__.py @@ -0,0 +1,13 @@ +# (C) Copyright IBM 2025. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +from adaptaqc.compilers.adapt.adapt_compiler import AdaptCompiler +from adaptaqc.compilers.adapt.adapt_config import AdaptConfig +from adaptaqc.compilers.adapt.adapt_result import AdaptResult diff --git a/utils/adapt-aqc/adaptaqc/compilers/adapt/adapt_compiler.py b/utils/adapt-aqc/adaptaqc/compilers/adapt/adapt_compiler.py new file mode 100644 index 0000000000000000000000000000000000000000..a5d1e31399dc8534e8615011f1da0068cfbacb68 --- /dev/null +++ b/utils/adapt-aqc/adaptaqc/compilers/adapt/adapt_compiler.py @@ -0,0 +1,1163 @@ +# (C) Copyright IBM 2025. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +"""Contains AdaptCompiler""" + +import logging +import os +import pickle +import timeit +from pathlib import Path + +import aqc_research.mps_operations as mpsops +import numpy as np +from qiskit import QuantumCircuit, qasm2 +from qiskit.compiler import transpile + +import adaptaqc.utils.ansatzes as ans +import adaptaqc.utils.constants as vconstants +from adaptaqc.backends.aer_sv_backend import AerSVBackend +from adaptaqc.backends.aqc_backend import AQCBackend +from adaptaqc.backends.itensor_backend import ITensorBackend +from adaptaqc.compilers.adapt.adapt_config import AdaptConfig +from adaptaqc.compilers.adapt.adapt_result import AdaptResult +from adaptaqc.compilers.approximate_compiler import ApproximateCompiler +from adaptaqc.utils import circuit_operations as co +from adaptaqc.utils import gradients as gr +from adaptaqc.utils.constants import CMAP_FULL, generate_coupling_map +from adaptaqc.utils.entanglement_measures import ( + EM_TOMOGRAPHY_CONCURRENCE, + calculate_entanglement_measure, +) +from adaptaqc.utils.utilityfunctions import ( + has_stopped_improving, + remove_permutations_from_coupling_map, + multi_qubit_gate_depth, +) + +logger = logging.getLogger(__name__) + + +class AdaptCompiler(ApproximateCompiler): + """ + Structure learning algorithm that incrementally builds a circuit that + has the same result when acting on |0> state + (computational basis) as the given circuit. + """ + + def __init__( + self, + target, + entanglement_measure=EM_TOMOGRAPHY_CONCURRENCE, + backend: AQCBackend = AerSVBackend(), + execute_kwargs=None, + coupling_map=None, + adapt_config: AdaptConfig = None, + general_initial_state=False, + custom_layer_2q_gate=None, + save_circuit_history=False, + starting_circuit=None, + use_roto_algos=True, + use_rotoselect=True, + use_advanced_transpilation=False, + rotosolve_fraction=1.0, + perform_final_minimisation=False, + optimise_local_cost=False, + soften_global_cost=False, + debug_log_full_ansatz=False, + initial_single_qubit_layer=False, + itensor_chi=None, + itensor_cutoff=None, + ): + """ + :param target: Circuit or MPS that is to be compiled + :param entanglement_measure: The entanglement measurement method to + use for quantifying local entanglement. Valid options are defined in + entanglement_measures.py. + :param backend: Backend to run circuits on. Valid options are defined in + circuit_operations_running.py. + :param execute_kwargs: keyword arguments passed onto AerBackend.run + :param coupling_map: 2-qubit gate coupling map to use + :param adapt_config: AdaptConfig object + :param general_initial_state: Compile circuit for an arbitrary + initial state. + :param custom_layer_2q_gate: A two-qubit QuantumCircuit which will be used as the ansatz + layers. + :param save_circuit_history: Option to regularly save circuit output as a QASM string to + results object each time a block is added and optimised + :param starting_circuit: This circuit will be used as a set of initial fixed gates for the + compiled solution. Importantly, the string "tenpy_product_state" can also be passed here. + In this case, TenPy will be used to find the best χ=1 compression of the target MPS/circuit + and start the compiled solution with the single-qubit gates that prepare this state. + :param use_roto_algos: Whether to use rotoselect and rotosolve for cost minimisation. + Disable if custom_layer_2q_gate does not support rotosolve + :param use_rotoselect: Whether to use rotoselect for cost minimisation. Disable if + not appropriate for chosen ansatz. + :param use_advanced_transpilation: Whether to use optimization_level=2 transpilation on + variational circuit before each call to rotosolve. This should result in fewer redundant + layers in the compiled circuit and faster optimisations. + :param rotosolve_fraction: During each rotosolve cycle, modify a random sample of the + available gates. Set to 1 to modify all available gates, 0.5 to modify half, etc. + :param perform_final_minimisation: Perform a final cost minimisation + once ADAPT-AQC has ended + :param optimise_local_cost: Choose the cost function with which to optimise layers: + - True: 'local' cost function: C_l = 1/2 * (1 - sum_i()/n) (arXiv:1908.04416, eq. 11) + - False: 'global' cost function: C_g = 1 - |<0|ψ>|^2 (arXiv:1908.04416, eq. 9) + ADAPT-AQC will still use the global cost function when deciding if compiling is completed. + :param soften_global_cost: Set to True to modify the global cost to: + C_ɑ = C_g - ɑ * sum_i(|<0|X_i|ψ>|^2) (arXiv:2301.08609, eq. 8). ɑ is chosen to be: + ɑ = |C' - C_s| where C' is the cost, C_ɑ, reached during optimisation of the previous layer, + and C_s is the sufficient cost. + :param debug_log_full_ansatz: When True, debug logging will print the entire ansatz at + every step, as opposed to just the most recently optimised layer. + :param initial_single_qubit_layer: When True, the first layer of the ADAPT-AQC ansatz will be + a trainable single-qubit rotation on each qubit. + """ + super().__init__( + target=target, + initial_state=None, + backend=backend, + execute_kwargs=execute_kwargs, + general_initial_state=general_initial_state, + starting_circuit=starting_circuit, + optimise_local_cost=optimise_local_cost, + itensor_chi=itensor_chi, + itensor_cutoff=itensor_cutoff, + rotosolve_fraction=rotosolve_fraction, + ) + + self.save_circuit_history = save_circuit_history + self.entanglement_measure_method = entanglement_measure + self.adapt_config = adapt_config if adapt_config is not None else AdaptConfig() + + if coupling_map is None: + coupling_map = generate_coupling_map( + self.total_num_qubits, CMAP_FULL, False, False + ) + + # If custom layer gate is provided, do not remove gate during ADAPT-AQC + # because individual gates + # might depend on each other. + self.remove_unnecessary_gates_during_adapt = custom_layer_2q_gate is None + self.use_roto_algos = use_roto_algos + self.use_rotoselect = use_rotoselect + self.use_advanced_transpilation = use_advanced_transpilation + if self.use_advanced_transpilation: + logger.warning( + "Using advanced qiskit transpilation (optimization_level=2) for variational circuit. This behaviour can be unpredicable with caching. You can turn this off in settings with use_advanced_transpilation=False." + ) + if self.use_rotoselect and custom_layer_2q_gate in [ + ans.u4(), + ans.fully_dressed_cnot(), + ans.heisenberg(), + ]: + logger.warning( + "For ansatz designed to perform physically motivated or universal operations Rotoselect may " + "cause change from expected behaviour" + ) + if not self.use_rotoselect and ( + custom_layer_2q_gate == ans.thinly_dressed_cnot() + or custom_layer_2q_gate == ans.identity_resolvable() + or custom_layer_2q_gate is None + ): + logger.warning("Rotoselect is necessary for convergence of chosen ansatz") + self.perform_final_minimisation = perform_final_minimisation + self.layer_2q_gate = self.construct_layer_2q_gate(custom_layer_2q_gate) + + # Remove permutations so that ADAPT-AQC is not stuck on the same pair of + # qubits + self.coupling_map = remove_permutations_from_coupling_map(coupling_map) + self.coupling_map = [ + (q1, q2) + for (q1, q2) in self.coupling_map + if q1 in self.qubit_subset_to_compile and q2 in self.qubit_subset_to_compile + ] + # Used to avoid adding thinly dressed CNOTs to the same qubit pair + self.qubit_pair_history = [] + # Avoid adding CNOTs to these qubit pairs + self.bad_qubit_pairs = [] + # Used to keep track of whether ADAPT-AQC/expectation method was used + self.pair_selection_method_history = [] + self.entanglement_measures_history = [] + self.e_val_history = [] + self.general_gradient_history = [] + self.time_taken = None + self.debug_log_full_ansatz = debug_log_full_ansatz + + self.initial_single_qubit_layer = initial_single_qubit_layer + + if self.is_aer_mps_backend: + # As variational gates will be absorbed into one large MPS instruction, we need to + # separately keep track of ansatz gates to return a compiled solution. + self.layers_saved_to_mps = self.full_circuit.copy() + del self.layers_saved_to_mps.data[1:] + + # Keep track of which layers have not been absorbed into the MPS + self.layers_as_gates = [] + + self.resume_from_layer = None + self.prev_checkpoint_time_taken = None + + if self.adapt_config.method == "general_gradient": + if not self.is_aer_mps_backend: + raise ValueError( + "general_gradient method is only implemented for Aer MPS backend" + ) + self.generators, self.degeneracies = gr.get_generators_and_degeneracies( + self.layer_2q_gate, use_rotoselect, inverse=True + ) + self.inverse_zero_ansatz = transpile(self.layer_2q_gate).inverse() + + self.soften_global_cost = soften_global_cost + if self.soften_global_cost and self.optimise_local_cost: + raise ValueError( + "soften_global_cost must be False when optimising local cost" + ) + + def construct_layer_2q_gate(self, custom_layer_2q_gate): + if custom_layer_2q_gate is None: + qc = QuantumCircuit(2) + if self.general_initial_state: + co.add_dressed_cnot(qc, 0, 1, True) + co.add_dressed_cnot(qc, 0, 1, True, v1=False, v2=False) + else: + co.add_dressed_cnot(qc, 0, 1, True) + return qc + else: + for i, circ_instr in enumerate(custom_layer_2q_gate): + gate = circ_instr.operation + if gate.label is None and gate.name in co.SUPPORTED_1Q_GATES: + gate.label = gate.name + custom_layer_2q_gate.data[i] = circ_instr + return custom_layer_2q_gate + + def get_layer_2q_gate(self, layer_index): + qc = self.layer_2q_gate.copy() + co.add_subscript_to_all_variables(qc, layer_index) + return qc + + def compile( + self, + initial_ansatz: QuantumCircuit = None, + optimise_initial_ansatz=True, + checkpoint_every=0, + checkpoint_dir="checkpoint/", + delete_prev_chkpt=False, + freeze_prev_layers=False, + ): + """ + Perform recompilation algorithm. + :param initial_ansatz: A trial ansatz to start the recompilation + with instead of starting from scratch + :param modify_initial_ansatz: If True, optimise the parameters of initial_ansatz when + intially adding it to the circuit. NOTE: the parameters of initial ansatz will be fixed for + the rest of recompilation. + :param checkpoint_every: If checkpoint_every = n != 0, compiler object will be saved to a + file after layers 0, n, 2n, ... have been added. + :param checkpoint_dir: Directory to place checkpoints in. Will be created if not already + existing. + :param delete_prev_chkpt: Delete the last checkpoint each time a new one is made. + :param freeze_prev_layers: When resuming compilation from a checkpoint, set to True to not + modify the parameters of any layers added before the checkpoint. + Termination criteria: SUFFICIENT_COST reached; max_layers reached; + std(last_5_costs)/avg(last_5_costs) < TOL + :return: AdaptResult object + """ + + start_time = timeit.default_timer() + if self.resume_from_layer is None: + self.time_taken = 0 + start_point = 0 + logger.info("ADAPT-AQC started") + logger.debug(f"ADAPT-AQC coupling map {self.coupling_map}") + self.cost_evaluation_counter = 0 + self.global_cost, self.local_cost = None, None + num_1q_gates, num_2q_gates, self.cnot_depth = None, None, None + + self.global_cost_history = [] + if self.optimise_local_cost: + self.local_cost_history = [] + self.circuit_history = [] + self.cnot_depth_history = [] + self.g_range = self.variational_circuit_range + self.original_lhs_gate_count = self.lhs_gate_count + + if freeze_prev_layers: + logger.warning( + "freeze_prev_layers only applies when resuming from a checkpoint" + ) + + # If an initial ansatz has been provided, add that and run minimization + self.initial_ansatz_already_successful = False + if initial_ansatz is not None: + self._add_initial_ansatz(initial_ansatz, optimise_initial_ansatz) + + else: + start_point = self.resume_from_layer + self.time_taken = self.prev_checkpoint_time_taken + logger.info(f"ADAPT-AQC resuming from layer: {start_point}") + if initial_ansatz is not None: + logger.warning( + "An initial ansatz will be ignored when resuming recompilation from a checkpoint" + ) + + if freeze_prev_layers: + if self.is_aer_mps_backend: + # Absorb all gates, apart from starting_circuit, into MPS and add gates to ref_circuit_as_gates + num_gates = len(self.full_circuit) - self.rhs_gate_count - 1 + gates_absorbed = self._absorb_n_gates_into_mps(n=num_gates) + co.add_to_circuit(self.layers_saved_to_mps, gates_absorbed) + self._update_reference_circuit() + else: + # Make lhs_gate_count include all layers added before checkpoint + self.lhs_gate_count = self.variational_circuit_range()[1] + + if checkpoint_every > 0: + Path(checkpoint_dir).mkdir(parents=True, exist_ok=True) + + for layer_count in range(start_point, self.adapt_config.max_layers): + if self.initial_ansatz_already_successful: + break + + logger.info(f"Global cost before adding layer: {self.global_cost}") + logger.info(f"CNOT depth before adding layer: {self.cnot_depth}") + if self.optimise_local_cost: + logger.info(f"Local cost before adding layer: {self.local_cost}") + self.local_cost = self._add_layer(layer_count) + self.global_cost = self.backend.evaluate_global_cost(self) + self.local_cost_history.append(self.local_cost) + else: + self.global_cost = self._add_layer(layer_count) + self.global_cost_history.append(self.global_cost) + self.record_cnot_depth() + + # Caching layers as MPS requires that the number of gates remain constant + if ( + self.remove_unnecessary_gates_during_adapt + and not self.is_aer_mps_backend + ): + co.remove_unnecessary_gates_from_circuit( + self.full_circuit, False, False, gate_range=self.g_range() + ) + + num_2q_gates, num_1q_gates = co.find_num_gates( + circuit=self.ref_circuit_as_gates + if self.is_aer_mps_backend + else self.full_circuit, + gate_range=self.g_range( + self.ref_circuit_as_gates if self.is_aer_mps_backend else None + ), + ) + + if self.save_circuit_history: + if not self.is_aer_mps_backend: + circuit_qasm_string = qasm2.dumps(self.full_circuit) + else: + circuit_copy = self.full_circuit.copy() + del circuit_copy.data[0] + circuit_qasm_string = qasm2.dumps(circuit_copy) + self.circuit_history.append(circuit_qasm_string) + + cinl = self.adapt_config.cost_improvement_num_layers + cit = self.adapt_config.cost_improvement_tol + if len(self.global_cost_history) >= cinl and has_stopped_improving( + self.global_cost_history[-1 * cinl :], cit + ): + logger.warning("ADAPT-AQC stopped improving") + self.compiling_finished = True + break + + if self.global_cost < self.adapt_config.sufficient_cost: + logger.info("ADAPT-AQC successfully found approximate circuit") + self.compiling_finished = True + break + elif num_2q_gates >= self.adapt_config.max_2q_gates: + logger.warning( + "ADAPT-AQC MAX_2Q_GATES reached. Using ROTOSOLVE one last time" + ) + # NOTE this may need changing to use a different stop_val when using local cost + self.minimizer.minimize_cost( + algorithm_kind=vconstants.ALG_ROTOSOLVE, + max_cycles=10, + tol=1e-5, + stop_val=self.adapt_config.sufficient_cost, + ) + self.compiling_finished = True + break + + if checkpoint_every > 0 and layer_count % checkpoint_every == 0: + self.checkpoint( + checkpoint_every, + checkpoint_dir, + delete_prev_chkpt, + layer_count, + start_time, + ) + + # Perform a final optimisation + if self.perform_final_minimisation: + self.minimizer.minimize_cost( + algorithm_kind=vconstants.ALG_PYBOBYQA, + alg_kwargs={"seek_global_minimum": False}, + ) + + if self.is_aer_mps_backend: + # Replace full_circuit with ref_circuit_as_gates, otherwise no way to remove unnecessary gates + self.full_circuit = self.ref_circuit_as_gates + + else: + # Reset lhs_gate_count to what it was at the start of compiling + self.lhs_gate_count = self.original_lhs_gate_count + + co.remove_unnecessary_gates_from_circuit( + self.full_circuit, True, True, gate_range=self.g_range() + ) + + # Calculate the final global cost, as 1 - ||^2 + if self.soften_global_cost: + self.soften_global_cost = False + final_global_cost = self.backend.evaluate_global_cost(self) + self.soften_global_cost = True + else: + final_global_cost = self.backend.evaluate_global_cost(self) + logger.info(f"Final global cost: {final_global_cost}") + self.global_cost_history.append(final_global_cost) + if checkpoint_every > 0: + self.checkpoint( + checkpoint_every, + checkpoint_dir, + delete_prev_chkpt, + len(self.qubit_pair_history) - 1, + start_time, + ) + compiled_circuit = self.get_compiled_circuit() + + num_2q_gates, num_1q_gates = co.find_num_gates(compiled_circuit) + final_cnot_depth = multi_qubit_gate_depth(compiled_circuit) + logger.info(f"Final CNOT depth: {final_cnot_depth}") + self.cnot_depth_history.append(final_cnot_depth) + + exact_overlap = "Not computable without SV backend" + if self.is_statevector_backend: + exact_overlap = co.calculate_overlap_between_circuits( + self.circuit_to_compile, + co.make_quantum_only_circuit(compiled_circuit), + ) + + result = AdaptResult( + circuit=compiled_circuit, + overlap=1 - final_global_cost, + exact_overlap=exact_overlap, + num_1q_gates=num_1q_gates, + num_2q_gates=num_2q_gates, + cnot_depth_history=self.cnot_depth_history, + global_cost_history=self.global_cost_history, + local_cost_history=self.local_cost_history + if self.optimise_local_cost + else None, + circuit_history=self.circuit_history, + entanglement_measures_history=self.entanglement_measures_history, + e_val_history=self.e_val_history, + qubit_pair_history=self.qubit_pair_history, + method_history=self.pair_selection_method_history, + time_taken=self.time_taken + (timeit.default_timer() - start_time), + cost_evaluations=self.cost_evaluation_counter, + coupling_map=self.coupling_map, + circuit_qasm=qasm2.dumps(compiled_circuit), + ) + + if self.save_circuit_history and self.is_aer_mps_backend: + logger.warning( + "When using MPS backend, circuit history will not contain the" + " set_matrix_product_state instruction at the start of the circuit" + ) + logger.info("ADAPT-AQC completed") + return result + + def checkpoint( + self, + checkpoint_every, + checkpoint_dir, + delete_prev_chkpt, + layer_count, + start_time, + ): + self.resume_from_layer = layer_count + 1 + current_chkpt_time_taken = timeit.default_timer() - start_time + self.prev_checkpoint_time_taken = self.time_taken + current_chkpt_time_taken + file_name = f"{layer_count}.pkl" + with open(os.path.join(checkpoint_dir, file_name), "wb") as f: + pickle.dump(self, f) + if delete_prev_chkpt: + try: + os.remove( + os.path.join( + checkpoint_dir, f"{layer_count - checkpoint_every}.pkl" + ) + ) + except FileNotFoundError: + pass + + def _debug_log_optimised_layer(self, layer_count): + if logger.getEffectiveLevel() == 10: + logger.debug(f"Qubit pair history: \n{self.qubit_pair_history}") + + if self.debug_log_full_ansatz: + if self.is_aer_mps_backend: + ansatz = self.ref_circuit_as_gates.copy() + else: + ansatz = self.full_circuit.copy() + del ansatz.data[: len(self.circuit_to_compile.data)] + logger.debug(f"Optimised ansatz after layer added: \n{ansatz}") + + layer_added = self._get_layer_added(layer_count) + if self.initial_single_qubit_layer == True and layer_count == 0: + logger.debug(f"Optimised layer added: \n{layer_added}") + else: + # Remove all qubits apart from the pair acted on in the current layer + for qubit in range(layer_added.num_qubits - 1, -1, -1): + if qubit not in self.qubit_pair_history[-1]: + del layer_added.qubits[qubit] + try: + logger.debug(f"Optimised layer added: \n{layer_added}") + except ValueError: + logging.error( + "Final ansatz layer logging not implemented for custom ansatz or functionalities " + "placing more gates after trainable ansatz" + ) + + def _add_initial_ansatz(self, initial_ansatz, optimise_initial_ansatz): + # Label ansatz gates to work with rotosolve + for gate in initial_ansatz: + if gate[0].label is None and gate[0].name in co.SUPPORTED_1Q_GATES: + gate[0].label = gate[0].name + + co.add_to_circuit( + self.full_circuit, + co.circuit_by_inverting_circuit(initial_ansatz), + self.variational_circuit_range()[1], + ) + if optimise_initial_ansatz: + if self.use_roto_algos: + cost = self.minimizer.minimize_cost( + algorithm_kind=vconstants.ALG_ROTOSOLVE, + tol=1e-3, + stop_val=0 + if self.optimise_local_cost + else self.adapt_config.sufficient_cost, + indexes_to_modify=self.variational_circuit_range(), + ) + else: + cost = self.minimizer.minimize_cost( + algorithm_kind=vconstants.ALG_PYBOBYQA, + alg_kwargs={"seek_global_minimum": True}, + ) + else: + cost = self.evaluate_cost() + + self.global_cost = ( + self.backend.evaluate_global_cost() if self.optimise_local_cost else cost + ) + self.cnot_depth = multi_qubit_gate_depth(initial_ansatz) + + if self.global_cost < self.adapt_config.sufficient_cost: + self.initial_ansatz_already_successful = True + logger.debug( + "ADAPT-AQC successfully found approximate circuit using provided ansatz only" + ) + + if self.is_aer_mps_backend: + # Absorb optimised initial_ansatz into MPS and add gates to ref_circuit_as_gates + gates_absorbed = self._absorb_n_gates_into_mps(n=len(initial_ansatz)) + co.add_to_circuit(self.layers_saved_to_mps, gates_absorbed) + self._update_reference_circuit() + else: + # Ensure initial_ansatz is not modified again + self.lhs_gate_count = self.variational_circuit_range()[1] + + def _add_layer(self, index): + """ + Adds a dressed CNOT gate or other ansatz layer to the qubits with the + highest local entanglement. If all qubit pairs have no + local entanglement, adds a dressed CNOT gate to the qubit pair with + the highest sum of expectation values + (computational basis). + :return: New cost + """ + + ansatz_start_index = self.variational_circuit_range()[0] + # Define first layer differently when initial_single_qubit_layer=True + if self.initial_single_qubit_layer and index == 0: + logger.debug( + "Starting with first layer comprising of only single qubit rotations" + ) + layer_added_optimisation_indexes = self._add_rotation_to_all_qubits() + else: + layer_added_optimisation_indexes = self._add_entangling_layer(index) + + if self.optimise_local_cost: + stop_val = 0 + else: + stop_val = self.adapt_config.sufficient_cost + + if self.use_roto_algos: + # Optimise layer currently being added + # For normal layers, use Rotoselect/Rotosolve if self.use_rotoselect=True/False + # For the initial_single_qubit_layer, use Rotoselect + + if self.use_rotoselect or (self.initial_single_qubit_layer and index == 0): + ALG = vconstants.ALG_ROTOSELECT + else: + ALG = vconstants.ALG_ROTOSOLVE + + cost = self.minimizer.minimize_cost( + algorithm_kind=ALG, + tol=self.adapt_config.rotoselect_tol, + stop_val=stop_val, + indexes_to_modify=layer_added_optimisation_indexes, + ) + # Do Rotosolve on previous max_layers_to_modify layers, when appropriate + if ( + self.adapt_config.rotosolve_frequency != 0 + and index > 0 + and index % self.adapt_config.rotosolve_frequency == 0 + ): + multi_layer_optimisation_indexes = ( + self._calculate_multi_layer_optimisation_indices(ansatz_start_index) + ) + if self.use_advanced_transpilation: + # Now do optimization_level=2 transpilation on variational circuit before calling rotosolve + variational_circuit = co.extract_inner_circuit( + self.full_circuit, self.variational_circuit_range() + ) + transpiled_variational_circuit = co.advanced_circuit_transpilation( + variational_circuit, self.coupling_map + ) + co.replace_inner_circuit( + self.full_circuit, + transpiled_variational_circuit, + self.variational_circuit_range(), + ) + if self.is_aer_mps_backend: + self._update_reference_circuit() + cost = self.minimizer.minimize_cost( + algorithm_kind=vconstants.ALG_ROTOSOLVE, + tol=self.adapt_config.rotosolve_tol, + stop_val=stop_val, + indexes_to_modify=multi_layer_optimisation_indexes, + ) + else: + cost = self.minimizer.minimize_cost( + algorithm_kind=vconstants.ALG_PYBOBYQA, + alg_kwargs={"seek_global_minimum": True}, + ) + + if self.is_aer_mps_backend: + self.layers_as_gates.append(index) + + num_layers_to_absorb = self._calculate_num_layers_to_absorb(index) + + # Absorb appropriate layers into MPS, and add their gates to layers_saved_to_mps + if num_layers_to_absorb > 0: + includes_isql = ( + self.layers_as_gates[0] == 0 and self.initial_single_qubit_layer + ) + + # Absorb layers into MPS, then add those layers to layers_saved_to_mps + num_gates_to_absorb = self._get_num_gates_to_cache( + n=num_layers_to_absorb, includes_isql=includes_isql + ) + if self.is_aer_mps_backend: + gates_absorbed = self._absorb_n_gates_into_mps(num_gates_to_absorb) + co.add_to_circuit(self.layers_saved_to_mps, gates_absorbed) + + # Update layers_as_gates + del self.layers_as_gates[:num_layers_to_absorb] + + if self.is_aer_mps_backend: + self._update_reference_circuit() + + self._debug_log_optimised_layer(index) + + return cost + + def _calculate_num_layers_to_absorb(self, index): + layers_since_solve = index % self.adapt_config.rotosolve_frequency + layers_to_next_solve = ( + self.adapt_config.rotosolve_frequency - layers_since_solve + ) + next_rotosolve_layer = index + layers_to_next_solve + + # Compute the index of the leftmost layer to be modified in the next Rotosolve + lowest_index = next_rotosolve_layer - self.adapt_config.max_layers_to_modify + 1 + + # All layers with indices below lowest_index can be absorbed + num_layers_to_absorb = len( + [i for i in self.layers_as_gates if i < lowest_index] + ) + + return num_layers_to_absorb + + def _update_reference_circuit(self): + # These are the layers now in circuit form, which are needed to update the reference circuit + layers_not_saved_to_mps = self.full_circuit.copy() + del layers_not_saved_to_mps.data[0] + + # Update ref_circuit_as_gates = layers_saved_to_mps + layers_not_saved_to_mps + self.ref_circuit_as_gates = self.layers_saved_to_mps.copy() + co.add_to_circuit(self.ref_circuit_as_gates, layers_not_saved_to_mps) + + def _calculate_multi_layer_optimisation_indices(self, ansatz_start_index): + num_entangling_layers = self.adapt_config.max_layers_to_modify - int( + self.initial_single_qubit_layer + ) + # This assumes first layer has n gates + num_gates_in_non_entangling_layer = self.full_circuit.num_qubits * int( + self.initial_single_qubit_layer + ) + # The earliest layer Rotosolve acts on is defined by the user. Calculating the + # index requires taking into account the first layer potentially being different + rotosolve_gate_start_index = max( + ansatz_start_index, + self.variational_circuit_range()[1] + - len(self.layer_2q_gate.data) * num_entangling_layers + - num_gates_in_non_entangling_layer, + ) + # Don't modify only a fraction of the first layer gates + first_layer_end_index = ansatz_start_index + num_gates_in_non_entangling_layer + if ansatz_start_index < rotosolve_gate_start_index < first_layer_end_index: + rotosolve_gate_start_index = first_layer_end_index + multi_layer_optimisation_indexes = ( + rotosolve_gate_start_index, + (self.variational_circuit_range()[1]), + ) + return multi_layer_optimisation_indexes + + def _add_entangling_layer(self, index): + logger.debug("Finding best qubit pair") + control, target = self._find_appropriate_qubit_pair() + logger.debug(f"Best qubit pair found {(control, target)}") + co.add_to_circuit( + self.full_circuit, + self.get_layer_2q_gate(index), + self.variational_circuit_range()[1], + qubit_subset=[control, target], + ) + self.qubit_pair_history.append((control, target)) + # Rotoselect or Rotosolve is applied to most recent layer + layer_added_optimisation_indexes = ( + self.variational_circuit_range()[1] - len(self.layer_2q_gate.data), + (self.variational_circuit_range()[1]), + ) + return layer_added_optimisation_indexes + + def _add_rotation_to_all_qubits(self): + first_layer = QuantumCircuit(self.full_circuit.num_qubits) + first_layer.ry(0, range(self.full_circuit.num_qubits)) + co.add_to_circuit( + self.full_circuit, first_layer, self.variational_circuit_range()[1] + ) + self._first_layer_increment_results_dict() + # Gate indices in the initial layer + initial_layer_optimisation_indexes = ( + self.variational_circuit_range()[1] - self.full_circuit.num_qubits, + (self.variational_circuit_range()[1]), + ) + return initial_layer_optimisation_indexes + + def _find_appropriate_qubit_pair(self): + if self.adapt_config.method == "random": + rand_index = np.random.randint(len(self.coupling_map)) + self.pair_selection_method_history.append(f"random") + return self.coupling_map[rand_index] + + if self.adapt_config.method == "basic": + # Choose the qubit pair with the highest reuse priority + self.pair_selection_method_history.append(f"basic") + reuse_priorities = self._get_all_qubit_pair_reuse_priorities(1) + return self.coupling_map[np.argmax(reuse_priorities)] + + if self.adapt_config.method == "expectation": + return self._find_best_expectation_qubit_pair() + + if self.adapt_config.method == "ISL": + logger.debug("Computing entanglement of pairs") + ems = self._get_all_qubit_pair_entanglement_measures() + self.entanglement_measures_history.append(ems) + return self._find_best_entanglement_qubit_pair(ems) + + if self.adapt_config.method == "general_gradient": + logger.debug("Computing gradients of pairs") + gradients = self._get_all_qubit_pair_gradients() + self.general_gradient_history.append(gradients) + self.pair_selection_method_history.append(f"general_gradient") + return self._find_best_gradient_qubit_pair(gradients) + + if self.adapt_config.method == "brickwall": + n = self.full_circuit.num_qubits + if n < 2: + raise ValueError( + "Cannot pick a pair if there are fewer than two qubits" + ) + if ( + len(self.qubit_pair_history) == 0 # This is the first layer + or n == 2 # There are only two qubits + or self.qubit_pair_history[-1][0] + is None # The first layer was single-qubit-layer + ): + return (0, 1) + + previous_pair = self.qubit_pair_history[-1] + next_pair = (previous_pair[0] + 2, previous_pair[1] + 2) + n_odd = n % 2 + if next_pair == (n, n + 1): + return (1 - n_odd, 2 - n_odd) + if next_pair == (n - 1, n): + return (0 + n_odd, 1 + n_odd) + else: + return next_pair + + raise ValueError( + f"Invalid compiling method {self.adapt_config.method}. " + f"Method must be one of ISL, expectation, random, basic, general_gradient, brickwall" + ) + + def _find_best_gradient_qubit_pair(self, gradients): + reuse_priorities = self._get_all_qubit_pair_reuse_priorities( + self.adapt_config.reuse_exponent + ) + combined_priority = np.multiply(gradients, reuse_priorities) + return self.coupling_map[np.argmax(combined_priority)] + + def _get_all_qubit_pair_gradients(self): + # Get the full_circuit without starting_circuit + if self.starting_circuit is not None: + range = (0, len(self.full_circuit) - len(self.starting_circuit)) + else: + range = (0, len(self.full_circuit)) + circuit = co.extract_inner_circuit(self.full_circuit, range) + gradients = gr.general_grad_of_pairs( + circuit, + self.inverse_zero_ansatz, + self.generators, + self.degeneracies, + self.coupling_map, + self.starting_circuit, + self.backend, + ) + logger.debug(f"Gradient of all pairs: {gradients}") + return gradients + + def _find_best_entanglement_qubit_pair(self, entanglement_measures): + """ + Returns the qubit pair with the largest entanglement multiplied by the reuse priority of + that pair. + """ + reuse_priorities = self._get_all_qubit_pair_reuse_priorities( + self.adapt_config.reuse_exponent + ) + + # First check if the previous qubit pair was 'bad' + if len(self.entanglement_measures_history) >= 2 + int( + self.initial_single_qubit_layer + ): + prev_qp_index = self.coupling_map.index(self.qubit_pair_history[-1]) + pre_em = self.entanglement_measures_history[-2][prev_qp_index] + post_em = self.entanglement_measures_history[-1][prev_qp_index] + if post_em >= pre_em: + logger.debug( + f"Entanglement did not reduce for previous pair {self.coupling_map[prev_qp_index]}. " + f"Adding to bad qubit pairs list." + ) + self.bad_qubit_pairs.append(self.coupling_map[prev_qp_index]) + if len(self.bad_qubit_pairs) > self.adapt_config.bad_qubit_pair_memory: + # Maintain max size of bad_qubit_pairs + logger.debug( + f"Max size of bad qubit pairs reached. Removing {self.bad_qubit_pairs[0]} from list." + ) + del self.bad_qubit_pairs[0] + + logger.debug(f"Entanglement of all pairs: {entanglement_measures}") + + # Combine entanglement value with reuse priority + filtered_ems = [ + entanglement_measure * reuse_priority + for (entanglement_measure, reuse_priority) in zip( + entanglement_measures, reuse_priorities + ) + ] + + for qp in set(self.bad_qubit_pairs): + # Find the number of times this qubit pair has occurred recently + reps = len( + [ + x + for x in self.qubit_pair_history[ + -1 * self.adapt_config.bad_qubit_pair_memory : + ] + if x == qp + ] + ) + if reps >= 1: + filtered_ems[self.coupling_map.index(qp)] = -1 + + logger.debug(f"Combined priority of all pairs: {filtered_ems}") + if max(filtered_ems) <= self.adapt_config.entanglement_threshold: + # No local entanglement detected in non-bad qubit pairs; + # defer to using 'basic' method + logger.info("No local entanglement detected in non-bad qubit pairs") + return self._find_best_expectation_qubit_pair() + else: + self.pair_selection_method_history.append(f"ISL") + # Add 'None' to e_val_history if no expectation values were needed + self.e_val_history.append(None) + return self.coupling_map[np.argmax(filtered_ems)] + + def _find_best_expectation_qubit_pair(self): + """ + Choose the qubit pair to be the one with the largest expectation value priority multiplied by the reuse + priority of that pair. + @return: The pair of qubits with the highest multiplied e_val priority and reuse priority. + """ + reuse_priorities = self._get_all_qubit_pair_reuse_priorities( + self.adapt_config.reuse_exponent + ) + + e_vals = self.backend.measure_qubit_expectation_values(self) + self.e_val_history.append(e_vals) + + e_val_sums = self._get_all_qubit_pair_e_val_sums(e_vals) + logger.debug(f"Summed σ_z expectation values of pairs {e_val_sums}") + + # Mapping from the σz expectation values {1, -1} to the range {0, 2} to make an expectation value based + # priority. This ensures that the argmax of the list favours qubits close to the |1> state (eigenvalue -1) + # to apply the next layer to. + e_val_priorities = [2 - e_val for e_val in e_val_sums] + + logger.debug(f"σ_z expectation value priorities of pairs {e_val_priorities}") + combined_priorities = [ + e_val_priority * reuse_priority + for (e_val_priority, reuse_priority) in zip( + e_val_priorities, reuse_priorities + ) + ] + logger.debug(f"Combined priorities of pairs {combined_priorities}") + self.pair_selection_method_history.append(f"expectation") + return self.coupling_map[np.argmax(combined_priorities)] + + def _get_all_qubit_pair_entanglement_measures(self): + entanglement_measures = [] + # Generate MPS from circuit once if using MPS backend + if isinstance(self.backend, ITensorBackend): + raise NotImplementedError("ISL mode not supported for ITensor") + if self.is_aer_mps_backend: + self.circ_mps = self.backend.evaluate_circuit(self) + else: + self.circ_mps = None + for control, target in self.coupling_map: + this_entanglement_measure = calculate_entanglement_measure( + self.entanglement_measure_method, + self.full_circuit, + control, + target, + self.backend, + self.backend_options, + self.execute_kwargs, + self.circ_mps, + ) + entanglement_measures.append(this_entanglement_measure) + return entanglement_measures + + def _get_all_qubit_pair_e_val_sums(self, e_vals): + e_val_sums = [] + for control, target in self.coupling_map: + e_val_sums.append(e_vals[control] + e_vals[target]) + return e_val_sums + + def _get_all_qubit_pair_reuse_priorities(self, k): + if not len(self.qubit_pair_history): + return [1 for _ in range(len(self.coupling_map))] + priorities = [] + for qp in self.coupling_map: + if self.adapt_config.reuse_priority_mode == "pair": + priorities.append(self._get_pair_reuse_priority(qp, k)) + elif self.adapt_config.reuse_priority_mode == "qubit": + priorities.append(self._get_qubit_reuse_priority(qp, k)) + else: + raise ValueError( + f"Reuse priority mode must be one of: {['pair', 'qubit']}" + ) + logger.debug(f"Reuse priorities of pairs: {priorities}") + return priorities + + def _find_last_use_of_qubit(self, qubit_pairs, qubit): + for index, tup in enumerate(qubit_pairs): + if qubit in tup: + return index + return np.inf + + def _get_qubit_reuse_priority(self, qubit_pair, k): + """ + Priority system based on how recently either of the qubits in a given pair were acted on. + The priority of a qubit pair (a,b) is given by: + 1. -1 if (a,b) was the last pair acted on + 2. 1 if k=0 + 3. 1 if a and b have never been acted on + 4. min[1-2^(-(la+1)/k), 1-2^(-(lb+1)/k)] where la (lb) is the number of layers since + qubit a (b) has been acted on. + + @param qubit_pair: Tuple where each element is the index of a qubit + @param k: Constant controlling how heavily recent pairs are disfavoured + """ + # Hard code that previous pair has priority -1 + if ( + len(self.qubit_pair_history) > 0 + int(self.initial_single_qubit_layer) + and qubit_pair == self.qubit_pair_history[-1] + ): + return -1 + # If not previous pair, then use exponential disfavouring + elif k == 0: + return 1 + else: + qubit_pairs_reversed = self.qubit_pair_history[::-1] + locs = [ + self._find_last_use_of_qubit(qubit_pairs_reversed, qubit) + for qubit in qubit_pair + ] + priorities = [1 - np.exp2(-(loc + 1) / k) for loc in locs] + return np.min(priorities) + + def _get_pair_reuse_priority(self, qubit_pair, k): + """ + Priority system based on how recently a specific pair of qubits were acted on. + The priority of a qubit pair (a,b) is given by: + 1. -1 if (a,b) was the last pair acted on + 2. 1 if k=0 + 3. 1 if (a,b) has never been acted on + 4. 1-2^(-l/k) l is the number of layers since the pair (a,b) has been acted on. + + @param qubit_pair: Tuple where each element is the index of a qubit + @param k: Constant controlling how heavily recent pairs are disfavoured + """ + # Hard code that previous pair has priority -1 + if ( + len(self.qubit_pair_history) > 0 + int(self.initial_single_qubit_layer) + and qubit_pair == self.qubit_pair_history[-1] + ): + return -1 + # If not previous pair, then use exponential disfavouring + elif k == 0: + return 1 + else: + qubit_pairs_reversed = self.qubit_pair_history[::-1] + try: + loc = qubit_pairs_reversed.index(qubit_pair) + priority = 1 - np.exp2(-loc / k) + return priority + except ValueError: + return 1 + + def _first_layer_increment_results_dict(self): + self.entanglement_measures_history.append([None]) + self.e_val_history.append(None) + self.general_gradient_history.append(None) + self.qubit_pair_history.append((None, None)) + self.pair_selection_method_history.append(None) + + def _get_layer_added(self, layer_count): + layer_added = ( + self.ref_circuit_as_gates.copy() + if self.is_aer_mps_backend + else self.full_circuit.copy() + ) + len_layer_added = len(self.layer_2q_gate) + # Remove starting_circuit from end of ansatz, if there is one + if self.starting_circuit is not None: + del layer_added.data[-len(self.starting_circuit.data) :] + if self.initial_single_qubit_layer == True and layer_count == 0: + del layer_added.data[: -layer_added.num_qubits] + return layer_added + else: + # Delete all gates apart from the 5 from the added layer + del layer_added.data[:-len_layer_added] + return layer_added + + def _get_num_gates_to_cache(self, n, includes_isql=False): + return len(self.layer_2q_gate) * ( + n - int(includes_isql) + ) + self.full_circuit.num_qubits * int(includes_isql) + + def _absorb_n_gates_into_mps(self, n): + """ + Takes full_circuit, which consists of a set_matrix_product_state instruction, followed by some number of ADAPT-AQC + gates and absorbs the first n of these gates (immediately after set_matrix_product_state) into the + set_matrix_product_state instruction. Also returns a copy of the gates absorbed as a QuantumCircuit. + + In other words it converts full_circuit from this: + -|0>--|mps(V†(k)U|0>)|--|N ADAPT-AQC gates |--|starting_circuit_inverse|- + To this: + -|0>--|mps(V†(k+n)U|0>)|--|N-n ADAPT-AQC gates|--|starting_circuit_inverse|- + + Where mps(V†(k)U|0>) is the set_matrix_product_state instruction representing the state after k gates + have been added. + + :param n: Number of gates to absorb. + :return: QuantumCircuit containing a copy of the gates which were absorbed. + + :param n: Number of gates to absorb. + :return: QuantumCircuit containing a copy of the gates which were absorbed. + """ + # +1 to include the initial set_matrix_product_state + num_gates_to_absorb = n + 1 + + # Get full_circuit up to and including gates to be absorbed + circ_to_absorb = self.full_circuit.copy() + del circ_to_absorb.data[num_gates_to_absorb:] + + # Keep a copy of what was absorbed to add to the reference circuit + gates_absorbed = circ_to_absorb.copy() + del gates_absorbed.data[0] + + # Get MPS of circ_to_absorb + circ_to_absorb_mps = mpsops.mps_from_circuit( + circ_to_absorb, sim=self.backend.simulator + ) + + # Create circuit with MPS instruction found above, with same registers as full_circuit + mps_circuit = QuantumCircuit(self.full_circuit.qregs[0]) + mps_circuit.set_matrix_product_state(circ_to_absorb_mps) + + # Replace absorbed part of full_circuit with its MPS instruction + num_gates_not_absorbed = len(self.full_circuit.data) - num_gates_to_absorb + if num_gates_not_absorbed != 0: + del self.full_circuit.data[:-num_gates_not_absorbed] + else: + del self.full_circuit.data[:] + self.full_circuit.data.insert(0, mps_circuit.data[0]) + + return gates_absorbed + + def record_cnot_depth(self): + if self.is_aer_mps_backend: + ansatz_circ = co.extract_inner_circuit( + self.ref_circuit_as_gates, + gate_range=(1, len(self.ref_circuit_as_gates)), + ) + else: + # Make sure initial ansatz and any "frozen" layers are included + ansatz_circ = co.extract_inner_circuit( + self.full_circuit, + gate_range=( + self.original_lhs_gate_count, + self.variational_circuit_range()[1], + ), + ) + self.cnot_depth = multi_qubit_gate_depth(ansatz_circ) + self.cnot_depth_history.append(self.cnot_depth) diff --git a/utils/adapt-aqc/adaptaqc/compilers/adapt/adapt_config.py b/utils/adapt-aqc/adaptaqc/compilers/adapt/adapt_config.py new file mode 100644 index 0000000000000000000000000000000000000000..ab4ba1e003971b2e792842c6bf9f7f2c724f50b5 --- /dev/null +++ b/utils/adapt-aqc/adaptaqc/compilers/adapt/adapt_config.py @@ -0,0 +1,97 @@ +# (C) Copyright IBM 2025. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +"""Contains AdaptConfig""" + +import adaptaqc.utils.constants as vconstants + + +class AdaptConfig: + def __init__( + self, + max_layers: int = int(1e5), + sufficient_cost=vconstants.DEFAULT_SUFFICIENT_COST, + max_2q_gates=1e4, + cost_improvement_num_layers=10, + cost_improvement_tol=1e-2, + max_layers_to_modify=100, + method="ISL", + bad_qubit_pair_memory=10, + reuse_exponent=0, + reuse_priority_mode="pair", + rotosolve_frequency=1, + rotoselect_tol=1e-5, + rotosolve_tol=1e-3, + entanglement_threshold=1e-8, + ): + """ + ADAPT-AQC termination criteria. + :param max_layers: ADAPT-AQC will terminate if the number of ansatz layers reaches this value. + :param sufficient_cost: ADAPT-AQC will terminate if the cost reaches below this value. + :param max_2q_gates: ADAPT-AQC will terminate if the number of 2 qubit gates reaches this value. + :param cost_improvement_num_layers: The number of layer costs to consider when evaluating + if the cost is decreasing fast enough. + :param cost_improvement_tol: ADAPT-AQC will terminate if in the last cost_improvement_num_layers, + the cost has not decreased by this value on average per layer. + + Add layer criteria: + :param max_layers_to_modify: The number of layers to modify, counting from the back of + the ansatz, when Rotosolve is used. + :param method: Method by which a qubit pair is prioritised for the next layer. One of: + 'ISL' - Largest pairwise entanglement as defined by AdaptCompiler.entanglement_measure + 'expectation' - Smallest combined σz expectation values (i.e., closest to min value of -2) + 'basic' - Pair not picked in the longest time + 'random' - Pair selected randomly + 'general_gradient' - Pair with the largest Euclidean norm of the global cost gradient with + respect to all parameters (θ) in the layer ansatz, evaluated at θ=0. This is the setting + used in https://arxiv.org/abs/2503.09683. + :param bad_qubit_pair_memory: For the ISL method, if acting on a qubit pair leads to + entanglement increasing, it is labelled a "bad pair". After this, for a number of layers + corresponding to the bad_qubit_pair_memory, this pair will not be selected. + :param reuse_exponent: For entanglement, expectation or general_gradient method, this + controls how much priority should be given to picking qubits not recently acted on. If 0, + the priority system is turned off and all qubits have the same reuse priority when adding + a new layer. Note ADAPT-AQC never reuses the same pair of qubits regardless of this setting. + :param reuse_priority_mode: For the priority system, given qubit pair (q1, q2) has been used + before, should priority be given to: + (a) not reusing the same pair of qubits (q1, q2) (set param to "pair") + (b) not reusing the qubits q1 OR q2 (set param to "qubit") + + Other parameters: + :param rotosolve_frequency: How often Rotosolve is used (if n, rotosolve will be used after + every n layers). + :param rotoselect_tol: How much does the cost need to decrease by each iteration to continue + Rotoselect. + :param rotosolve_tol: How much does the cost need to decrease by each iteration to continue + Rotosolve. + :param entanglement_threshold: For the ISL method, entanglement below this value is treated + as zero in terms of picking the next layer. + """ + self.bad_qubit_pair_memory = bad_qubit_pair_memory + self.max_layers = max_layers + self.sufficient_cost = sufficient_cost + self.max_2q_gates = max_2q_gates + self.cost_improvement_tol = cost_improvement_tol + self.cost_improvement_num_layers = int(cost_improvement_num_layers) + self.max_layers_to_modify = max_layers_to_modify + self.method = method + self.rotosolve_frequency = rotosolve_frequency + self.rotoselect_tol = rotoselect_tol + self.rotosolve_tol = rotosolve_tol + self.entanglement_threshold = entanglement_threshold + self.reuse_exponent = reuse_exponent + self.reuse_priority_mode = reuse_priority_mode.lower() + + def __repr__(self): + representation_str = f"{self.__class__.__name__}(" + for k, v in self.__dict__.items(): + representation_str += f"{k}={v!r}, " + representation_str += ")" + return representation_str diff --git a/utils/adapt-aqc/adaptaqc/compilers/adapt/adapt_result.py b/utils/adapt-aqc/adaptaqc/compilers/adapt/adapt_result.py new file mode 100644 index 0000000000000000000000000000000000000000..c22d0d25a2c61e2333718d97b7d33f8f513c6f51 --- /dev/null +++ b/utils/adapt-aqc/adaptaqc/compilers/adapt/adapt_result.py @@ -0,0 +1,70 @@ +# (C) Copyright IBM 2025. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +"""Contains AdaptResult""" + + +class AdaptResult: + def __init__( + self, + circuit, + overlap, + exact_overlap, + num_1q_gates, + num_2q_gates, + cnot_depth_history, + global_cost_history, + local_cost_history, + circuit_history, + entanglement_measures_history, + e_val_history, + qubit_pair_history, + method_history, + time_taken, + cost_evaluations, + coupling_map, + circuit_qasm, + ): + """ + :param circuit: Resulting circuit. + :param overlap: 1 - final_global_cost. + :param exact_overlap: Only computable with SV backend. + :param num_1q_gates: Number of rotation gates in circuit. + :param num_2q_gates: Number of entangling gates in circuit. + :param cnot_depth_history: Depth of ansatz after each layer when only considering 2-qubit gates. + :param global_cost_history: List of global costs after each layer. + :param local_cost_history: List of local costs after each layer (if applicable). + :param circuit_history: List of circuits as qasm strings after each layer (if applicable). + :param entanglement_measures_history: List of pairwise entanglements after each layer. + :param e_val_history: List of single-qubit sigma_z expectation values after each layer. + :param qubit_pair_history: List of qubit pair acted on in each layer. + :param method_history: List of methods used to select qubit pairs for each layer. + :param time_taken: Total time taken for recompilation. + :param cost_evaluations: Total number of cost evalutions during recompilation. + :param coupling_map: List of allowed qubit connections. + :param circuit_qasm: QASM string of the resulting circuit. + """ + self.circuit = circuit + self.overlap = overlap + self.exact_overlap = exact_overlap + self.num_1q_gates = num_1q_gates + self.num_2q_gates = num_2q_gates + self.cnot_depth_history = cnot_depth_history + self.global_cost_history = global_cost_history + self.local_cost_history = local_cost_history + self.circuit_history = circuit_history + self.entanglement_measures_history = entanglement_measures_history + self.e_val_history = e_val_history + self.qubit_pair_history = qubit_pair_history + self.method_history = method_history + self.time_taken = time_taken + self.cost_evaluations = cost_evaluations + self.coupling_map = coupling_map + self.circuit_qasm = circuit_qasm diff --git a/utils/adapt-aqc/adaptaqc/compilers/approximate_compiler.py b/utils/adapt-aqc/adaptaqc/compilers/approximate_compiler.py new file mode 100644 index 0000000000000000000000000000000000000000..021281b56833e96758617663db1b9b6fae5f0f21 --- /dev/null +++ b/utils/adapt-aqc/adaptaqc/compilers/approximate_compiler.py @@ -0,0 +1,527 @@ +# (C) Copyright IBM 2025. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +""" +Contains ApproximateCcompiler +""" +import logging +import multiprocessing +import os +import timeit +from abc import ABC, abstractmethod + +from aqc_research.mps_operations import mps_from_circuit, check_mps +from qiskit import ClassicalRegister, QuantumCircuit, QuantumRegister +from qiskit.providers import Backend + +from adaptaqc.backends.aer_mps_backend import AerMPSBackend +from adaptaqc.backends.aqc_backend import AQCBackend +from adaptaqc.backends.itensor_backend import ITensorBackend +from adaptaqc.backends.python_default_backends import QASM_SIM +from adaptaqc.backends.qiskit_sampling_backend import QiskitSamplingBackend +from adaptaqc.utils import circuit_operations as co +from adaptaqc.utils.circuit_operations.circuit_operations_full_circuit import ( + remove_classical_operations, +) +from adaptaqc.utils.constants import QiskitMPS +from adaptaqc.utils.cost_minimiser import CostMinimiser +from adaptaqc.utils.utilityfunctions import ( + is_statevector_backend, + qiskit_to_tenpy_mps, + tenpy_chi_1_mps_to_circuit, +) + +logger = logging.getLogger(__name__) + + +class CompileInPartsResult: + def __init__( + self, + circuit, + overlap, + individual_results, + time_taken, + ): + """ + :param circuit: Resulting circuit. + :param overlap: 1 - final_global_cost. + :param individual_results: List of result objects for each sub-recompilation. + :param time_taken: Total time taken for recompilation. + """ + self.circuit = circuit + self.overlap = overlap + self.individual_results = individual_results + self.time_taken = time_taken + + +class ApproximateCompiler(ABC): + """ + Variational hybrid quantum-classical algorithm that compiles a given + circuit into another circuit. The new circuit + has the same result when acting on the given input state as the given + circuit. + """ + + full_circuit: QuantumCircuit + + def __init__( + self, + target: QuantumCircuit | QiskitMPS, + backend: AQCBackend, + execute_kwargs=None, + initial_state=None, + qubit_subset=None, + general_initial_state=False, + starting_circuit=None, + optimise_local_cost=False, + soften_global_cost=False, + itensor_chi=None, + itensor_cutoff=None, + rotosolve_fraction=1.0, + ): + """ + :param target: Circuit or MPS that is to be compiled + :param backend: Backend that is to be used + :param execute_kwargs: keyword arguments passed down to Qiskit AerBackend.run + e.g. {'noise_model:NoiseModel, shots=10000} + + :param initial_state: Can be used to define an initial state to compile with respect to + (as opposed to the default of the |0..0> state). Effectively redefines the cost function as + C = 1 - ||^2. Similar functionality can be achieved for AdaptCompiler with + the `starting_circuit` param, but here the solution won't prepare the initial state - it + assumes the initial state is already prepared. Can be a circuit (QuantumCircuit/Instruction) + or vector (list/np.ndarray) or None + + :param qubit_subset: The subset of qubits (relative to initial state + circuit) that target acts + on. If None, it will be assumed that target and + initial_state circuit have the same qubits + :param general_initial_state: Whether recompilation should be for a + general initial state + """ + self.target = target + self.original_circuit_classical_ops = None + self.backend = backend if backend is not None else QASM_SIM + self.is_statevector_backend = is_statevector_backend(self.backend) + self.is_aer_mps_backend = isinstance(self.backend, AerMPSBackend) + if isinstance(self.backend, ITensorBackend): + logger.warning( + "ITensor is an experimental backend with many missing features" + ) + self.itensor_target = None + self.itensor_chi = itensor_chi + self.itensor_cutoff = itensor_cutoff + if check_mps(self.target) and not self.is_aer_mps_backend: + raise Exception("Aer MPS backend must be used when target is an Aer MPS") + self.circuit_to_compile = self.prepare_circuit() + self.execute_kwargs = self.parse_default_execute_kwargs(execute_kwargs) + self.backend_options = self.parse_default_backend_options() + self.initial_state_circuit = co.initial_state_to_circuit(initial_state) + self.total_num_qubits = self.calculate_total_num_qubits() + self.qubit_subset_to_compile = ( + qubit_subset if qubit_subset else list(range(self.total_num_qubits)) + ) + self.general_initial_state = general_initial_state + self.starting_circuit = self.prepare_starting_circuit(starting_circuit) + self.zero_mps = mps_from_circuit( + QuantumCircuit(self.total_num_qubits), return_preprocessed=True + ) + self.optimise_local_cost = optimise_local_cost + self.soften_global_cost = soften_global_cost + + if initial_state is not None and general_initial_state: + raise ValueError( + "Can't compile for general initial state when specific " + "initial state is provided" + ) + + ( + self.full_circuit, + self.lhs_gate_count, + self.rhs_gate_count, + ) = self._prepare_full_circuit() + if 0 < rotosolve_fraction <= 1: + self.minimizer = CostMinimiser( + self.evaluate_cost, + self.variational_circuit_range, + self.full_circuit, + rotosolve_fraction, + ) + else: + raise ValueError("rotosolve_fraction must be in the range (0,1]") + + # Count number of cost evaluations + self.cost_evaluation_counter = 0 + + self.compiling_finished = False + + def prepare_circuit(self): + """ + Constructs a circuit from the target which will then be compiled. This is composed of four + possible parts: + 1. Remove classical operations from circuit + 2. Transpile circuit to BASIS_GATES + 3. Find MPS representation of target circuit + 4. Create circuit with set_matrix_product_state instruction generating the MPS found in 3. + + For the four combinations of (target, backend), prepare_circuit performs: + (circuit, non-mps): 1 -> 2 -> return circuit + (circuit, mps): 1 -> 2 -> 3 -> 4 -> return circuit + (mps, non-mps): exception will have been raised already + (mps, mps): 4 -> return circuit + """ + # Check if target is Aer MPS + if check_mps(self.target): + target_mps = self.target + target_mps_circuit = QuantumCircuit(len(target_mps[0])) + target_mps_circuit.set_matrix_product_state(target_mps) + return target_mps_circuit + else: + target_copy = self.target.copy() + self.original_circuit_classical_ops = remove_classical_operations( + target_copy + ) + qc2 = QuantumCircuit(len(self.target.qubits)) + qc2.append( + co.make_quantum_only_circuit(target_copy).to_instruction(), qc2.qregs[0] + ) + prepared_circuit = co.unroll_to_basis_gates(qc2) + if self.is_aer_mps_backend: + logger.info("Pre-computing target circuit as MPS using AerSimulator") + target_mps = mps_from_circuit( + prepared_circuit, sim=self.backend.simulator + ) + target_mps_circuit = QuantumCircuit(prepared_circuit.num_qubits) + target_mps_circuit.set_matrix_product_state(target_mps) + # Return a circuit with the target MPS embedded inside + return target_mps_circuit + if isinstance(self.backend, ITensorBackend): + from itensornetworks_qiskit.utils import qiskit_circ_to_it_circ + from juliacall import Main as jl + + jl.seval("using ITensorNetworksQiskit") + logger.info("Pre-computing target circuit as MPS using ITensor") + gates = qiskit_circ_to_it_circ(prepared_circuit) + n = self.target.num_qubits + self.itensor_sites = jl.generate_siteindices_itensors(n) + self.itensor_target = jl.mps_from_circuit_itensors( + n, gates, self.itensor_chi, self.itensor_cutoff, self.itensor_sites + ) + return prepared_circuit + + def prepare_starting_circuit(self, starting_circuit): + if starting_circuit is None or isinstance(starting_circuit, QuantumCircuit): + return starting_circuit + elif starting_circuit == "tenpy_product_state": + if isinstance(self.backend, AerMPSBackend): + trunc_thr = ( + self.backend.simulator.options.matrix_product_state_truncation_threshold + ) + else: + trunc_thr = 1e-8 + tenpy_mps = qiskit_to_tenpy_mps( + mps_from_circuit(self.circuit_to_compile.copy(), trunc_thr=trunc_thr) + ) + + compression_options = { + "compression_method": "variational", + "trunc_params": {"chi_max": 1}, + "max_trunc_err": 1, + "max_sweeps": 50, + "min_sweeps": 10, + } + tenpy_mps.compress(compression_options) + + return tenpy_chi_1_mps_to_circuit(tenpy_mps) + else: + raise ValueError( + "starting_circuit must be a QuantumCircuit, None, or string: 'tenpy_product_state'" + ) + + def parse_default_execute_kwargs(self, execute_kwargs): + kwargs = {} if execute_kwargs is None else dict(execute_kwargs) + if "shots" not in kwargs: + if isinstance(self.backend, QiskitSamplingBackend): + kwargs["shots"] = 8192 + else: + kwargs["shots"] = 1 + if "optimization_level" not in kwargs: + kwargs["optimization_level"] = 0 + return kwargs + + def parse_default_backend_options(self): + backend_options = {} + if ( + "noise_model" in self.execute_kwargs + and self.execute_kwargs["noise_model"] is not None + ): + backend_options["method"] = "automatic" + else: + backend_options["method"] = "automatic" + + try: + if os.environ["QISKIT_IN_PARALLEL"] == "TRUE": + # Already in parallel + backend_options["max_parallel_experiments"] = 1 + else: + num_threads = multiprocessing.cpu_count() + backend_options["max_parallel_experiments"] = num_threads + logger.debug( + "Circuits will be evaluated with {} experiments in " + "parallel".format(num_threads) + ) + os.environ["KMP_WARNINGS"] = "0" + + except KeyError: + logger.debug( + "No OMP number of threads defined. Qiskit will autodiscover " + "the number of parallel shots to run" + ) + return backend_options + + def calculate_total_num_qubits(self): + if self.initial_state_circuit is None: + total_num_qubits = self.circuit_to_compile.num_qubits + else: + total_num_qubits = self.initial_state_circuit.num_qubits + return total_num_qubits + + def variational_circuit_range(self, circuit=None): + if circuit == None: + circuit = self.full_circuit + return self.lhs_gate_count, len(circuit.data) - self.rhs_gate_count + + def ansatz_range(self): + return self.lhs_gate_count, len(self.full_circuit.data) + + def _starting_circuit_range(self): + end = len(self.full_circuit.data) + start = end - self.rhs_gate_count + return start, end + + @abstractmethod + def compile(self): + """ + Run the recompilation algorithm + :return: Result object (AdaptResult, FixedAnsatzResult, RotoselectResult) containing the + resulting circuit, the overlap between original and resulting circuit, and other optional + entries (such as circuit parameters). + """ + raise NotImplementedError( + "A compiler must provide implementation for the compile() " "method" + ) + + def compile_in_parts(self, max_depth_per_block=10): + """ + Compiles the circuit using the following procedure: First break + the circuit into n subcircuits. + Then iteratively find an approximation recompilation for the first + m+1 subcircuits by finding an approximate + of (approx_circuit_for_first_m_subcircuits + (m+1)th subcircuit) + :param max_depth_per_block: The maximum allowed depth of each of the + n subcircuits + :return: CompileInPartsResult object + """ + logger.info("Started partial recompilation") + start_time = timeit.default_timer() + + all_subcircuits = co.vertically_divide_circuit( + self.circuit_to_compile.copy(), max_depth_per_block + ) + + logger.info( + f"Circuit was split into {len(all_subcircuits)} parts to compile sequentially" + ) + + last_compiled_subcircuit = None + individual_results = [] + for subcircuit in all_subcircuits: + co.replace_inner_circuit( + self.full_circuit, + last_compiled_subcircuit, + self.variational_circuit_range(), + True, + {"backend": self.backend.simulator}, + ) + co.add_to_circuit( + self.full_circuit, + subcircuit, + self.variational_circuit_range()[1], + True, + {"backend": self.backend.simulator}, + ) + partial_recompilation_result = self.compile() + last_compiled_subcircuit = partial_recompilation_result.circuit + partial_recompilation_result.circuit = None + individual_results.append(partial_recompilation_result) + percentage = ( + 100 * (1 + all_subcircuits.index(subcircuit)) / len(all_subcircuits) + ) + logger.info(f"Completed {percentage}% of recompilation") + + end_time = timeit.default_timer() + + result = CompileInPartsResult( + circuit=last_compiled_subcircuit, + overlap=co.calculate_overlap_between_circuits( + last_compiled_subcircuit, + self.circuit_to_compile, + self.initial_state_circuit, + self.qubit_subset_to_compile, + ), + individual_results=individual_results, + time_taken=end_time - start_time, + ) + + return result + + def get_compiled_circuit(self): + compiled_circuit = co.circuit_by_inverting_circuit( + co.extract_inner_circuit( + self.full_circuit, self.variational_circuit_range() + ) + ) + if self.starting_circuit is not None: + transpile_kwargs = ( + {"backend": self.backend} + if (isinstance(self.backend, Backend)) + else None + ) + co.add_to_circuit( + compiled_circuit, + self.starting_circuit, + 0, + transpile_before_adding=True, + transpile_kwargs=transpile_kwargs, + ) + final_circuit = QuantumCircuit( + *self.circuit_to_compile.qregs, *self.circuit_to_compile.cregs + ) + qubit_map = { + full_circ_index: subset_index + for subset_index, full_circ_index in enumerate(self.qubit_subset_to_compile) + } + co.add_to_circuit(final_circuit, compiled_circuit, qubit_subset=qubit_map) + + # If self.target is a QuantumCircuit object, this ensures the quantum and classical registers of the compiled + # circuit are the same as those of the target. If self.target is an MPS, there were no registers in the first + # place, so this makes a QuantumCircuit with the default register names + if isinstance(self.target, QuantumCircuit): + final_circuit_original_regs = QuantumCircuit( + *self.target.qregs, *self.target.cregs + ) + else: + final_circuit_original_regs = QuantumCircuit( + self.circuit_to_compile.num_qubits + ) + + final_circuit_original_regs.append( + final_circuit.to_instruction(), final_circuit_original_regs.qubits + ) + circuit_no_classical_ops = co.unroll_to_basis_gates(final_circuit_original_regs) + if self.original_circuit_classical_ops is not None: + co.add_classical_operations( + circuit_no_classical_ops, self.original_circuit_classical_ops + ) + return circuit_no_classical_ops + + def _prepare_full_circuit(self): + """Circuit is of form: + -|0>--|initial_state|--|circuit_to_compile + |--|variational_circuit|--|initial_state_inverse|--|(measure)| + With this circuit, the overlap between circuit_to_compile and + inverse of full_circuit + w.r.t initial_state is just the probability of resulting state + being in all zero |00...00> state + If self.general_initial_state is true, circuit takes a different + form described in the papers below. + (refer to arXiv:1811.03147, arXiv:1908.04416) + """ + total_qubits = ( + 2 * self.total_num_qubits + if self.general_initial_state + else self.total_num_qubits + ) + qr = QuantumRegister(total_qubits) + qc = QuantumCircuit(qr) + + # TODO update this to use new custom backend class + transpile_kwargs = ( + {"backend": self.backend} if (isinstance(self.backend, Backend)) else None + ) + + if self.initial_state_circuit is not None: + co.add_to_circuit( + qc, + self.initial_state_circuit, + transpile_before_adding=True, + transpile_kwargs=transpile_kwargs, + ) + elif self.general_initial_state: + for qubit in range(self.total_num_qubits): + qc.h(qubit) + qc.cx(qubit, qubit + self.total_num_qubits) + + co.add_to_circuit( + qc, + self.circuit_to_compile, + transpile_before_adding=False, + qubit_subset=self.qubit_subset_to_compile, + ) + + lhs_gate_count = len(qc.data) + + if self.initial_state_circuit is not None: + isc = co.unroll_to_basis_gates(self.initial_state_circuit) + co.remove_reset_gates(isc) + co.add_to_circuit( + qc, + isc.inverse(), + transpile_before_adding=True, + transpile_kwargs=transpile_kwargs, + ) + if self.starting_circuit is not None: + co.add_to_circuit( + qc, + self.starting_circuit.inverse(), + transpile_before_adding=True, + transpile_kwargs=transpile_kwargs, + ) + elif self.general_initial_state: + for qubit in range(self.total_num_qubits - 1, -1, -1): + qc.cx(qubit, qubit + self.total_num_qubits) + qc.h(qubit) + + if self.backend == QASM_SIM: + if self.optimise_local_cost: + register_size = 2 if self.general_initial_state else 1 + qc.add_register(ClassicalRegister(register_size, name="compiler_creg")) + else: + qc.add_register(ClassicalRegister(total_qubits, name="compiler_creg")) + [qc.measure(i, i) for i in range(total_qubits)] + + rhs_gate_count = len(qc.data) - lhs_gate_count + + return qc, lhs_gate_count, rhs_gate_count + + def evaluate_cost(self): + """ + Run the full circuit and evaluate the overlap. + The cost function is the Loschmidt Echo Test as defined in arXiv:1908.04416. + "Global" and "local" cost functions refer to equations 9 and 11 respectively, + (also illustrated in Figure 2 (a) and (b) respectively) + :return: Cost (float) + """ + self.cost_evaluation_counter += 1 + + if self.optimise_local_cost: + return self.backend.evaluate_local_cost(self) + else: + return self.backend.evaluate_global_cost(self) diff --git a/utils/adapt-aqc/adaptaqc/utils/__init__.py b/utils/adapt-aqc/adaptaqc/utils/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..2502d9d003b76036dc954f6d039c933e0b0404c5 --- /dev/null +++ b/utils/adapt-aqc/adaptaqc/utils/__init__.py @@ -0,0 +1,11 @@ +# (C) Copyright IBM 2025. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +from adaptaqc.utils.circuit_operations import * \ No newline at end of file diff --git a/utils/adapt-aqc/adaptaqc/utils/ansatzes.py b/utils/adapt-aqc/adaptaqc/utils/ansatzes.py new file mode 100644 index 0000000000000000000000000000000000000000..bf412c0a1b0f013e02973147a3ade578a80a9001 --- /dev/null +++ b/utils/adapt-aqc/adaptaqc/utils/ansatzes.py @@ -0,0 +1,100 @@ +# (C) Copyright IBM 2025. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +from qiskit import QuantumCircuit + + +def u4(): + """ + U(4) ansatz from Fig. 6 of + Vatan, Farrokh, and Colin Williams. "Optimal quantum circuits for general two-qubit gates." + Physical Review A 69.3 (2004): 032315. + """ + qc = QuantumCircuit(2) + qc.rz(0, 0) + qc.ry(0, 0) + qc.rz(0, 0) + qc.rz(0, 1) + qc.ry(0, 1) + qc.rz(0, 1) + qc.cx(1, 0) + qc.rz(0, 0) + qc.ry(0, 1) + qc.cx(0, 1) + qc.ry(0, 1) + qc.cx(1, 0) + qc.rz(0, 0) + qc.ry(0, 0) + qc.rz(0, 0) + qc.rz(0, 1) + qc.ry(0, 1) + qc.rz(0, 1) + return qc + + +def thinly_dressed_cnot(): + qc = QuantumCircuit(2) + qc.rx(0, 0) + qc.rx(0, 1) + qc.cx(0, 1) + qc.rx(0, 0) + qc.rx(0, 1) + return qc + + +def fully_dressed_cnot(): + qc = QuantumCircuit(2) + qc.rz(0, 0) + qc.ry(0, 0) + qc.rz(0, 0) + qc.rz(0, 1) + qc.ry(0, 1) + qc.rz(0, 1) + qc.cx(0, 1) + qc.rz(0, 0) + qc.ry(0, 0) + qc.rz(0, 0) + qc.rz(0, 1) + qc.ry(0, 1) + qc.rz(0, 1) + return qc + + +def identity_resolvable(): + qc = QuantumCircuit(2) + qc.rx(0, 0) + qc.rx(0, 1) + qc.cx(0, 1) + qc.rx(0, 0) + qc.rx(0, 1) + qc.cx(0, 1) + qc.rx(0, 0) + qc.rx(0, 1) + return qc + + +def heisenberg(): + """ + Based on fig 2. from N. Robertson et al. "Approximate Quantum Compiling for Quantum Simulation: A Tensor Network + based approach" arxiv:2301.08609, which gives circuit representing two site evolution operator + e^(iαXX + iβYY + iγZZ) corresponding to XYZ Heisenberg model with no field. Here, we additionally allow for the Rz + gates applied (at the end) to the first qubit and (at the start) to the second qubit to be trainable, to mimic + additional (learnable) evolution under an external field (effectively a first order trotter expansion). + """ + qc = QuantumCircuit(2) + qc.rz(0.0, 1) + qc.cx(1, 0) + qc.rz(0.0, 0) + qc.ry(0.0, 1) + qc.cx(0, 1) + qc.ry(0.0, 1) + qc.cx(1, 0) + qc.rz(0.0, 0) + return qc diff --git a/utils/adapt-aqc/adaptaqc/utils/circuit_operations/__init__.py b/utils/adapt-aqc/adaptaqc/utils/circuit_operations/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..5bf6b4b31a962ab25b70f5bdb5eafe49afa807aa --- /dev/null +++ b/utils/adapt-aqc/adaptaqc/utils/circuit_operations/__init__.py @@ -0,0 +1,17 @@ +# (C) Copyright IBM 2025. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +from adaptaqc.utils.circuit_operations.circuit_operations_basic import * +from adaptaqc.utils.circuit_operations.circuit_operations_circuit_division import * +from adaptaqc.utils.circuit_operations.circuit_operations_full_circuit import * +from adaptaqc.utils.circuit_operations.circuit_operations_optimisation import * +from adaptaqc.utils.circuit_operations.circuit_operations_pauli_ops import * +from adaptaqc.utils.circuit_operations.circuit_operations_running import * +from adaptaqc.utils.circuit_operations.circuit_operations_variational import * diff --git a/utils/adapt-aqc/adaptaqc/utils/circuit_operations/circuit_operations_basic.py b/utils/adapt-aqc/adaptaqc/utils/circuit_operations/circuit_operations_basic.py new file mode 100644 index 0000000000000000000000000000000000000000..c31ff8d7721a44b155f7cbe5301388187cbcc558 --- /dev/null +++ b/utils/adapt-aqc/adaptaqc/utils/circuit_operations/circuit_operations_basic.py @@ -0,0 +1,262 @@ +# (C) Copyright IBM 2025. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +import random + +import numpy as np +from qiskit import QuantumCircuit +from qiskit.circuit import Gate, CircuitInstruction +from qiskit.circuit.library import RXGate, RYGate, RZGate, CZGate, CXGate +from sympy import parse_expr + + +def create_1q_gate(gate_name, angle): + """ + Create 1 qubit rotation gate with given name and angle + :param gate_name: Name of rotation gate ('rx','ry','rz') + :param angle: Angle of rotation + :return: New gate + """ + if gate_name == "rx": + return RXGate(angle, label="rx") + elif gate_name == "ry": + return RYGate(angle, label="ry") + elif gate_name == "rz": + return RZGate(angle, label="rz") + else: + raise ValueError(f"Unsupported gate {gate_name}") + + +def create_2q_gate(gate_name): + """ + Create 2 qubit gate with given name + :param gate_name: Name of rotation gate ('cx','cz') + :return: New gate + """ + if gate_name == "cx": + return CXGate() + elif gate_name == "cz": + return CZGate() + else: + raise ValueError("Unsupported gate") + + +def add_gate( + circuit: QuantumCircuit, + gate, + gate_index=None, + qubit_indexes=None, + clbit_indexes=None, +): + if gate_index is None: + gate_index = len(circuit.data) + qubits = ( + [circuit.qubits[i] for i in qubit_indexes] if qubit_indexes is not None else [] + ) + clbits = ( + [circuit.clbits[i] for i in clbit_indexes] if clbit_indexes is not None else [] + ) + circ_instr = CircuitInstruction(operation=gate, qubits=qubits, clbits=clbits) + circuit.data.insert(gate_index, circ_instr) + + +def replace_1q_gate(circuit, gate_index, gate_name, angle): + """ + Replace the gate at the specified index of circuit + :param circuit: QuantumCircuit + :param gate_index: The index of the gate that is to be replaced + :param gate_name: New gate name + :param angle: New gate angle + """ + if gate_name is None: + return + circ_instr = circuit.data[gate_index] + qargs = circ_instr.qubits + cargs = circ_instr.clbits + if "#" in gate_name: + circuit.data[gate_index] = CircuitInstruction( + operation=create_independent_parameterised_gate( + *gate_name.split("#"), angle + ), + qubits=qargs, + clbits=cargs, + ) + reevaluate_dependent_parameterised_gates( + circuit, calculate_independent_variable_values(circuit) + ) + elif "@" in gate_name: + raise ValueError("Cant replace dependent parameterised gate") + else: + circuit.data[gate_index] = CircuitInstruction( + operation=create_1q_gate(gate_name, angle), qubits=qargs, clbits=cargs + ) + + +def replace_2q_gate(circuit, gate_index, control, target, gate_name="cx"): + """ + Replace the gate at the specified index of circuit + :param circuit: QuantumCircuit + :param gate_index: The index of the gate that is to be replaced + :param control: New gate control qubit + :param target: New gate target qubit + :param gate_name: New gate name + """ + circ_instr = circuit.data[gate_index] + old_qargs = circ_instr.qubits + cargs = circ_instr.clbits + + qr = old_qargs[0]._register + new_qargs = [qr[control], qr[target]] + new_gate = create_2q_gate(gate_name) + circuit.data[gate_index] = CircuitInstruction( + operation=new_gate, qubits=new_qargs, clbits=cargs + ) + + +def is_supported_1q_gate(gate): + if not isinstance(gate, Gate): + return False + gate_name = gate.label if gate.label is not None else gate.name + + if "@" in gate_name: + return False + if "#" in gate_name: + gate_name = gate_name.split("#")[0] + return gate_name in SUPPORTED_1Q_GATES + + +def add_appropriate_gates(circuit, qubit, thinly_dressed, loc): + ry_gate = create_1q_gate("ry", 0) + rz_gate = create_1q_gate("rz", 0) + add_gate(circuit, rz_gate.copy(), loc, [qubit]) + loc += 1 + if not thinly_dressed: + add_gate(circuit, ry_gate.copy(), loc, [qubit]) + loc += 1 + add_gate(circuit, rz_gate.copy(), loc, [qubit]) + loc += 1 + return loc + + +def add_dressed_cnot( + circuit: QuantumCircuit, + control, + target, + thinly_dressed=False, + gate_index=None, + v1=True, + v2=True, + v3=True, + v4=True, +): + """ + Add a dressed cnot gate (cx surrounded by 4 general-rotation(rzryrz + decomposition) gates) + :param circuit: QuantumCircuit + :param control: Control qubit + :param target: Target qubit + :param thinly_dressed: Whether only a single rz gate should be added + instead of the 3 gate rzryrz decomposition + :param gate_index: The location of the dressed CNOT gate in circuit.data + (gates are added to the end if None) + :param v1: Whether there should be rotation gates before control qubit + :param v2: Whether there should be rotation gates before target qubit + :param v3: Whether there should be rotation gates after control qubit + :param v4: Whether there should be rotation gates after target qubit + """ + if gate_index is None: + gate_index = len(circuit.data) + + cx_gate = create_2q_gate("cx") + + if v1: + gate_index = add_appropriate_gates(circuit, control, thinly_dressed, gate_index) + if v2: + gate_index = add_appropriate_gates(circuit, target, thinly_dressed, gate_index) + + add_gate(circuit, cx_gate.copy(), gate_index, [control, target]) + gate_index += 1 + if v3: + gate_index = add_appropriate_gates(circuit, control, thinly_dressed, gate_index) + if v4: + add_appropriate_gates(circuit, target, thinly_dressed, gate_index) + + +def random_1q_gate(): + """ + Create rotation gate with random angle and axis randomly chosen from x,y,z + :return: New gate + """ + return create_1q_gate( + random.choice(SUPPORTED_1Q_GATES), random.uniform(-np.pi, np.pi) + ) + + +SUPPORTED_1Q_GATES = ["rx", "ry", "rz"] +SUPPORTED_2Q_GATES = ["cx", "cz"] +BASIS_GATES = ["u3", "cx", "cz", "rx", "ry", "rz", "x", "y", "z", "XY", "ZZ", "h"] +DEFAULT_GATES = ["rz", "rx", "ry", "u1", "u2", "u3", "cx", "id", "measure", "reset"] + + +def create_independent_parameterised_gate(gate_type, variable_name, angle=0): + gate = create_1q_gate(gate_type, angle) + gate.label = f"{gate.label}#{variable_name}" + return gate + + +def create_dependent_parameterised_gate(gate_type, equation_string, angle=0): + gate = create_1q_gate(gate_type, angle) + gate.label = f"{gate.label}@{equation_string}" + return gate + + +def calculate_independent_variable_values(circuit: QuantumCircuit): + variable_dict = {} + for circ_instr in circuit.data: + gate = circ_instr.operation + if gate.label is not None and "#" in gate.label: + variable_name = gate.label.split("#")[1] + variable_value = gate.params[0] + variable_dict[variable_name] = variable_value + return variable_dict + + +def reevaluate_dependent_parameterised_gates( + circuit: QuantumCircuit, independent_variable_values +): + for i, circ_instr in enumerate(circuit.data): + gate = circ_instr.operation + if gate.label is not None and "@" in gate.label: + equation = gate.label.split("@")[1] + result = parse_expr(equation, independent_variable_values) + angle = float(result) + gate.params[0] = angle + circuit.data[i] = circ_instr + + +def add_subscript_to_all_variables(circuit: QuantumCircuit, subscript_value): + substitution_dict = {} + for i, circ_instr in enumerate(circuit.data): + gate = circ_instr.operation + if gate.label is not None and "#" in gate.label: + gate_type, variable_name = gate.label.split("#") + gate.label = f"{gate_type}#{variable_name}_{subscript_value}" + circuit.data[i] = circ_instr + + substitution_dict[variable_name] = f"{variable_name}_{subscript_value}" + + for i, circ_instr in enumerate(circuit.data): + gate = circ_instr.operation + if gate.label is not None and "@" in gate.label: + gate_type, equation = gate.label.split("@") + for old_name, new_name in substitution_dict.items(): + equation = equation.replace(old_name, new_name) + gate.label = f"{gate_type}@{equation}" + circuit.data[i] = circ_instr diff --git a/utils/adapt-aqc/adaptaqc/utils/circuit_operations/circuit_operations_circuit_division.py b/utils/adapt-aqc/adaptaqc/utils/circuit_operations/circuit_operations_circuit_division.py new file mode 100644 index 0000000000000000000000000000000000000000..01c6b62cf65682152bd1ea1c55247756cda1cc34 --- /dev/null +++ b/utils/adapt-aqc/adaptaqc/utils/circuit_operations/circuit_operations_circuit_division.py @@ -0,0 +1,144 @@ +# (C) Copyright IBM 2025. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +from qiskit import QuantumCircuit +from qiskit.circuit import Clbit, Instruction, Qubit + +from adaptaqc.utils.circuit_operations.circuit_operations_full_circuit import ( + unroll_to_basis_gates, +) + + +def find_previous_gate_on_qubit(circuit, gate_index): + """ + Find the gate just before specified gate that acts on at least 1 same + qubit as the specified gate + :param circuit: QuantumCircuit + :param gate_index: The index of the specified gate + :return: (previous_gate_object, index) (or (None,None) if no such gate) + """ + # circuit.data has form list[CircuitInstruction], with: + # gate_object = CircuitInstruction.operation + # [(register, qubit)] = CircuitInstruction.qubits + # cargs = CircuitInstruction.clbits + required_qubits = set(circuit.data[gate_index].qubits) + index = gate_index - 1 + while index >= 0: + circ_instr = circuit.data[index] + gate = circ_instr.operation + qargs = circ_instr.qubits + # If at least one of the qargs (register, qubit) of the gate is the + # same as the qargs of the specified circuit + if len(required_qubits & set(qargs)) > 0: + return gate, index + index -= 1 + return None, None + + +def index_of_bit_in_circuit(bit, circuit): + """ + Calculate the index of the qubit/clbit in the circuit. + Qubit/clbit index is relative to Quantum/ClassicalRegister + :param bit: Qubit or Clbit + :param circuit: QuantumCircuit + :return: + """ + if isinstance(bit, Qubit): + return circuit.qubits.index(bit) + elif isinstance(bit, Clbit): + return circuit.clbits.index(bit) + else: + raise TypeError(f"{bit} not a Qubit or Clbit") + + +def calculate_next_gate_indexes( + current_next_gate_indexes, circuit, gate_qargs, gate_cargs +): + """ + Pre-emptively calculates the index at which gates yet to applied will + be placed in the circuit. For n > 1 bit gates, all bits involved in + that gate action will have the same index, calculated as 1 + the + largest position for the list of relevant bits. + :param circuit: QuantumCircuit + :param current_next_gate_indexes: Current next_gate_indexes + :param gate_qargs: Qubits the gate acts on + :param gate_cargs: Clbits the gate acts on + :return: New next_gate_indexes + """ + qubit_indexes = [index_of_bit_in_circuit(qubit, circuit) for qubit in gate_qargs] + clbit_indexes = [ + len(circuit.qubits) + index_of_bit_in_circuit(clbit, circuit) + for clbit in gate_cargs + ] + + largest_index = max( + current_next_gate_indexes[i] for i in qubit_indexes + clbit_indexes + ) + + resulting_next_gate_indexes = list(current_next_gate_indexes) + for i in qubit_indexes + clbit_indexes: + resulting_next_gate_indexes[i] = largest_index + 1 + + return resulting_next_gate_indexes + + +def vertically_divide_circuit(circuit, max_depth_per_division=10): + """ + ----------|----|----|---|--------- + ----- ____|____|____|____|__ ----- + -----| | | | | |----- + -----|____|____|____|____|__|----- + ----------|----|----|---|--------- + :param circuit: Circuit to divide (QuantumCircuit/Instruction) + :param max_depth_per_division: Upper limit of depth of each of the + subcircuits resulting from the division + :return List of subcircuits [QuantumCircuit] + """ + if isinstance(circuit, Instruction): + if circuit.num_clbits > 0: + remaining_circuit = QuantumCircuit(circuit.num_qubits, circuit.num_clbits) + else: + remaining_circuit = QuantumCircuit(circuit.num_qubits) + remaining_circuit.append( + circuit, remaining_circuit.qubits, remaining_circuit.clbits + ) + else: + remaining_circuit = circuit.copy() + + remaining_circuit = unroll_to_basis_gates(remaining_circuit) + all_subcircuits = [] + while len(remaining_circuit) > 0: + subcircuit = QuantumCircuit(*remaining_circuit.qregs, *remaining_circuit.cregs) + gate_indexes_to_remove = [] + next_gate_indexes = [0] * ( + len(remaining_circuit.qubits) + len(remaining_circuit.clbits) + ) + for i in range(len(remaining_circuit.data)): + circ_instr = remaining_circuit.data[i] + instr = circ_instr.operation + qargs = circ_instr.qubits + cargs = circ_instr.clbits + + next_gate_indexes = calculate_next_gate_indexes( + next_gate_indexes, remaining_circuit, qargs, cargs + ) + + if max(next_gate_indexes) <= max_depth_per_division: + subcircuit.append(instr, qargs, cargs) + gate_indexes_to_remove.append(i) + elif min(next_gate_indexes) >= max_depth_per_division: + break + + for j in reversed(gate_indexes_to_remove): + del remaining_circuit.data[j] + + all_subcircuits.append(subcircuit) + + return all_subcircuits diff --git a/utils/adapt-aqc/adaptaqc/utils/circuit_operations/circuit_operations_full_circuit.py b/utils/adapt-aqc/adaptaqc/utils/circuit_operations/circuit_operations_full_circuit.py new file mode 100644 index 0000000000000000000000000000000000000000..20c6e310d8be29f2c38676368b164dffe2d6e569 --- /dev/null +++ b/utils/adapt-aqc/adaptaqc/utils/circuit_operations/circuit_operations_full_circuit.py @@ -0,0 +1,465 @@ +# (C) Copyright IBM 2025. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +import multiprocessing +import random +from typing import Union + +import numpy as np +from qiskit import ClassicalRegister, QuantumCircuit, QuantumRegister +from qiskit import transpile as qiskit_transpile +from qiskit.circuit import Clbit, Gate, Instruction, Qubit, Reset, CircuitInstruction +from qiskit.quantum_info import random_statevector, Statevector + +from adaptaqc.utils.circuit_operations import ( + BASIS_GATES, + DEFAULT_GATES, + SUPPORTED_1Q_GATES, + SUPPORTED_2Q_GATES, +) +from adaptaqc.utils.circuit_operations.circuit_operations_basic import ( + add_gate, + create_1q_gate, + create_2q_gate, +) + + +def find_register(circuit, bit): + for reg in circuit.qregs + circuit.cregs: + if bit in reg: + return reg + return None + + +def find_bit_index(reg, bit): + for i, reg_bit in enumerate(reg): + if bit == reg_bit: + return i + return None + + +def create_random_circuit( + num_qubits, depth=5, one_qubit_gates=None, two_qubit_gates=None +): + qc = QuantumCircuit(num_qubits) + if one_qubit_gates is None: + one_qubit_gates = SUPPORTED_1Q_GATES + if two_qubit_gates is None: + two_qubit_gates = SUPPORTED_2Q_GATES + rs = np.random.RandomState(multiprocessing.current_process().pid) + while qc.depth() < depth: + random_gate = rs.choice(one_qubit_gates + two_qubit_gates) + if random_gate in one_qubit_gates: + qubits = rs.choice(list(range(num_qubits)), [1]) + add_gate( + qc, + create_1q_gate(random_gate, random.uniform(-np.pi, np.pi)), + qubit_indexes=qubits, + ) + elif random_gate in two_qubit_gates: + qubits = rs.choice(list(range(num_qubits)), [2], replace=False) + add_gate(qc, create_2q_gate(random_gate), qubit_indexes=qubits) + return qc + + +def are_circuits_identical( + qc1: QuantumCircuit, qc2: QuantumCircuit, match_labels=False, match_registers=False +): + if len(qc1.data) != len(qc2.data): + return False + for (gate1, qargs1, cargs1), (gate2, qargs2, cargs2) in zip(qc1.data, qc2.data): + # Checks that gates match + gate1_name = gate1.label if gate1.label is not None else gate1.name + gate2_name = gate2.label if gate2.label is not None else gate2.name + + if gate1_name != gate2_name: + return False + + if len(gate1.params) != len(gate2.params) and gate1_name in ["rx", "ry", "rz"]: + gate1_params = [gate1.params[0]] + gate2_params = [gate2.params[0]] + else: + gate1_params = gate1.params + gate2_params = gate2.params + + if gate1_params != gate2_params: + return False + + if match_labels and gate1.label != gate2.label: + return False + + # Check that qargs match + for qubit1, qubit2 in zip(qargs1, qargs2): + if match_registers and qubit1 != qubit2: + return False + if qubit1._index != qubit2._index: + return False + + # Check that cargs match + for clbit1, clbit2 in zip(cargs1, cargs2): + if match_registers and clbit1 != clbit2: + return False + if clbit1._index != clbit2._index: + return False + return True + + +def change_circuit_register( + circuit: QuantumCircuit, + new_circuit_reg: Union[QuantumRegister, ClassicalRegister], + bit_mapping=None, +): + """ + Only supports 1 quantum/classical register circuits + :param circuit: + :param new_circuit_reg: + :param bit_mapping: + """ + change_quantum = isinstance(new_circuit_reg, QuantumRegister) + if change_quantum: + old_reg = circuit.qregs[0] + # If qregs used in multiple circuits (e.g. if circuit is copied) + # then don't affect other circuits + circuit.qregs = circuit.qregs.copy() + num_bits = circuit.num_qubits + else: + old_reg = circuit.cregs[0] + # If cregs used in multiple circuits (e.g. if circuit is copied) + # then don't affect other circuits + circuit.cregs = circuit.cregs.copy() + num_bits = circuit.num_clbits + bit_mapping = {} if bit_mapping is None else bit_mapping + for bit in range(num_bits): + if bit not in bit_mapping: + bit_mapping[bit] = bit + + # Add new register to circuit if necessary + if new_circuit_reg not in circuit.qregs + circuit.cregs: + if change_quantum: + circuit.qregs = [] + circuit.add_register(new_circuit_reg) + else: + circuit.cregs = [] + circuit.add_register(new_circuit_reg) + + for index, circ_instr in enumerate(circuit.data): + if change_quantum: + new_qargs = [ + Qubit(new_circuit_reg, bit_mapping[find_bit_index(old_reg, qubit)]) + for qubit in circ_instr.qubits + ] + circuit.data[index] = CircuitInstruction( + operation=circ_instr.operation, + qubits=new_qargs, + clbits=circ_instr.clbits, + ) + else: + new_cargs = [ + Clbit(new_circuit_reg, bit_mapping[find_bit_index(old_reg, clbit)]) + for clbit in circ_instr.clbits + ] + circuit.data[index] = CircuitInstruction( + operation=circ_instr.operation, + qubits=circ_instr.qubits, + clbits=new_cargs, + ) + + +def add_to_circuit( + original_circuit: QuantumCircuit, + circuit_to_be_added: QuantumCircuit, + location=None, + transpile_before_adding=False, + transpile_kwargs=None, + qubit_subset=None, + clbit_subset=None, +): + """ + Only supports 1 quantum register circuits + :param original_circuit: + :param circuit_to_be_added: + :param location: + :param transpile_before_adding: + :param transpile_kwargs: + :param qubit_subset: + :param clbit_subset: + """ + circuit_to_be_added_copy = circuit_to_be_added.copy() + if location is None: + location = len(original_circuit.data) + if transpile_before_adding: + circuit_to_be_added_copy = unroll_to_basis_gates( + circuit_to_be_added_copy, DEFAULT_GATES + ) + if transpile_kwargs is not None: + circuit_to_be_added_copy = transpile( + circuit_to_be_added_copy, **transpile_kwargs + ) + qubit_mapping = None + if qubit_subset is not None: + qubit_mapping = ( + {index: value for index, value in enumerate(qubit_subset)} + if isinstance(qubit_subset, list) + else qubit_subset + ) + + clbit_mapping = None + if clbit_subset is not None: + clbit_mapping = {index: value for index, value in enumerate(clbit_subset)} + + # Change quantum register + change_circuit_register( + circuit_to_be_added_copy, + find_register(original_circuit, original_circuit.qubits[0]), + qubit_mapping, + ) + + # Change classical register if present + if len(circuit_to_be_added_copy.clbits) > 0 and len(original_circuit.clbits) > 0: + change_circuit_register( + circuit_to_be_added_copy, + find_register(original_circuit, original_circuit.clbits[0]), + clbit_mapping, + ) + + for gate in circuit_to_be_added_copy: + original_circuit.data.insert(location, gate) + location += 1 + + +def remove_inner_circuit(circuit: QuantumCircuit, gate_range_to_remove): + for index in list(range(*gate_range_to_remove))[::-1]: + del circuit.data[index] + + +def extract_inner_circuit(circuit: QuantumCircuit, gate_range): + inner_circuit = QuantumCircuit() + [inner_circuit.add_register(qreg) for qreg in circuit.qregs] + [inner_circuit.add_register(creg) for creg in circuit.cregs] + for gate_index in range(*gate_range): + circ_instr = circuit.data[gate_index] + inner_circuit.data.append(circ_instr) + return inner_circuit + + +def replace_inner_circuit( + circuit: QuantumCircuit, + inner_circuit_replacement, + gate_range, + transpile_before_adding=False, + transpile_kwargs=None, +): + remove_inner_circuit(circuit, gate_range) + if ( + inner_circuit_replacement is not None + and len(inner_circuit_replacement.data) > 0 + ): + add_to_circuit( + circuit, + inner_circuit_replacement, + gate_range[0], + transpile_before_adding=transpile_before_adding, + transpile_kwargs=transpile_kwargs, + ) + + +def find_num_gates( + circuit, transpile_before_counting=False, transpile_kwargs=None, gate_range=None +): + """ + Find the number of 2 qubit and 1 qubit (non classical) gates in circuit + :param circuit: QuantumCircuit + :param transpile_before_counting: Whether circuit should be transpiled + before counting + :param transpile_kwargs: transpile kwargs (e.g {'backend':backend}) + :param gate_range: The range of gates to include in search space (full + circuit if None) + :return: (num_2q_gates, num_1q_gates) + """ + if circuit is None: + return 0, 0 + if transpile_before_counting: + if transpile_kwargs is None: + circuit = unroll_to_basis_gates(circuit) + else: + circuit = transpile(circuit, **transpile_kwargs) + if gate_range is None: + gate_range = (0, len(circuit.data)) + num_2q_gates = 0 + num_1q_gates = 0 + for gate_index in range(*gate_range): + if ( + len(circuit.data[gate_index].qubits) == 1 + and len(circuit.data[gate_index].clbits) == 0 + ): + num_1q_gates += 1 + elif ( + len(circuit.data[gate_index].qubits) == 2 + and len(circuit.data[gate_index].clbits) == 0 + ): + num_2q_gates += 1 + return num_2q_gates, num_1q_gates + + +def transpile(circuit, **transpile_kwargs): + if transpile_kwargs is None: + transpile_kwargs = {} + + return qiskit_transpile(circuit, **transpile_kwargs) + + +def unroll_to_basis_gates(circuit, basis_gates=None): + """ + Create circuit by unrolling given circuit to basis_gates + :param circuit: Circuit to unroll + :param basis_gates: Basis gate set to unroll to (BASIS_GATES by default) + :return: Transpiled circuit + """ + basis_gates = basis_gates if basis_gates is not None else BASIS_GATES + return transpile(circuit, basis_gates=basis_gates, optimization_level=0) + + +def append_to_instruction(main_ins, ins_to_append): + qc = QuantumCircuit(main_ins.num_qubits) + if main_ins.definition is not None and len(main_ins.definition) > 0: + qc.append(main_ins, qc.qubits) + if ins_to_append.definition is not None and len(ins_to_append.definition) > 0: + qc.append(ins_to_append, qc.qubits) + return qc.to_instruction() + + +def remove_classical_operations(circuit: QuantumCircuit): + gates_and_locations = [] + for index, circ_instr in list(enumerate(circuit.data))[::-1]: + if len(circ_instr.clbits) > 0: + gates_and_locations.append((index, circ_instr)) + del circuit.data[index] + return gates_and_locations[::-1] + + +def add_classical_operations(circuit: QuantumCircuit, gates_and_locations): + for index, circ_instr in gates_and_locations: + circuit.data.insert(index, circ_instr) + + +def make_quantum_only_circuit(circuit: QuantumCircuit): + new_qc = QuantumCircuit(*circuit.qregs) + no_classical_circuit = circuit.copy() + remove_classical_operations(no_classical_circuit) + for i in no_classical_circuit.data: + new_qc.data.append(i) + # remove_classical_operations(new_qc) + # new_qc.cregs = [] + # new_qc.clbits = [] + return new_qc + + +def circuit_by_inverting_circuit(circuit: QuantumCircuit): + new_circuit = QuantumCircuit(*circuit.qregs, *circuit.cregs) + + for circ_instr in circuit.data[::-1]: + gate = circ_instr.operation + if not isinstance(gate, Gate): + new_circuit.data.append(circ_instr) + continue + if gate.label not in ["rx", "ry", "rz"]: + inverted_gate = gate.inverse().to_mutable() + else: + inverted_gate = gate.copy() + inverted_gate.params[0] *= -1 + inverted_gate.label = gate.label + inverted_circ_instr = CircuitInstruction( + operation=inverted_gate, qubits=circ_instr.qubits, clbits=circ_instr.clbits + ) + new_circuit.data.append(inverted_circ_instr) + return new_circuit + + +def initial_state_to_circuit(initial_state): + """ + Convert to QuantumCircuit + :param initial_state: Can either be a circuit ( + QuantumCircuit/Instruction) or vector (list/np.ndarray) or None + :return: QuantumCircuit or None + """ + if initial_state is None: + return None + elif isinstance(initial_state, (list, np.ndarray)): + num_qubits = int(np.log2(len(initial_state))) + qc = QuantumCircuit(num_qubits) + qc.initialize(initial_state, qc.qubits) + # Unrolling will remove 'reset' gates from circuit + qc = unroll_to_basis_gates(qc) + remove_reset_gates(qc) + return qc + elif isinstance(initial_state, Instruction): + num_qubits = initial_state.num_qubits + qc = QuantumCircuit(num_qubits) + qc.append(initial_state, qc.qubits) + return qc + elif isinstance(initial_state, QuantumCircuit): + return initial_state.copy() + else: + raise TypeError("Invalid type of initial_state provided") + + +def calculate_overlap_between_circuits( + circuit1, circuit2, initial_state=None, qubit_subset=None +): + initial_state_circuit = initial_state_to_circuit(initial_state) + if initial_state_circuit is None: + total_num_qubits = circuit1.num_qubits + else: + total_num_qubits = initial_state_circuit.num_qubits + + qubit_subset_to_compile = ( + qubit_subset if qubit_subset else list(range(total_num_qubits)) + ) + qr1 = QuantumRegister(total_num_qubits) + qr2 = QuantumRegister(total_num_qubits) + qc1 = QuantumCircuit(qr1) + qc2 = QuantumCircuit(qr2) + + if initial_state_circuit is not None: + add_to_circuit(qc1, initial_state_circuit) + add_to_circuit(qc2, initial_state_circuit) + qc1.append(circuit1, [qr1[i] for i in qubit_subset_to_compile]) + qc2.append(circuit2, [qr2[i] for i in qubit_subset_to_compile]) + + sv1 = Statevector(qc1) + sv2 = Statevector(qc2) + return np.absolute(np.vdot(sv1, sv2)) ** 2 + + +def create_random_initial_state_circuit( + num_qubits, return_statevector=False, seed=None +): + seed = seed() if None else seed + rand_state = random_statevector(2**num_qubits, seed).data + qc = QuantumCircuit(num_qubits) + qc.initialize(rand_state, qc.qubits) + qc = unroll_to_basis_gates(qc) + + # Delete reset gates + for i in range(len(qc.data) - 1, -1, -1): + gate = qc.data[i].operation + if isinstance(gate, Reset): + del qc.data[i] + + if return_statevector: + return qc, rand_state + else: + return qc + + +def remove_reset_gates(circuit: QuantumCircuit): + for i, circ_instr in list(enumerate(circuit.data))[::-1]: + if isinstance(circ_instr.operation, Reset): + del circuit.data[i] diff --git a/utils/adapt-aqc/adaptaqc/utils/circuit_operations/circuit_operations_optimisation.py b/utils/adapt-aqc/adaptaqc/utils/circuit_operations/circuit_operations_optimisation.py new file mode 100644 index 0000000000000000000000000000000000000000..b8dd7fe44c50006293e2789255cb341dcd39d142 --- /dev/null +++ b/utils/adapt-aqc/adaptaqc/utils/circuit_operations/circuit_operations_optimisation.py @@ -0,0 +1,231 @@ +# (C) Copyright IBM 2025. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +import numpy as np +from qiskit import QuantumCircuit +from qiskit.compiler import transpile +from qiskit.synthesis import OneQubitEulerDecomposer + +from adaptaqc.utils.circuit_operations.circuit_operations_basic import ( + is_supported_1q_gate, + replace_1q_gate, +) +from adaptaqc.utils.circuit_operations.circuit_operations_circuit_division import ( + find_previous_gate_on_qubit, +) +from adaptaqc.utils.constants import ( + get_initial_layout, + convert_cmap_to_qiskit_format, +) + +MINIMUM_ROTATION_ANGLE = 1e-3 + + +def remove_unnecessary_gates_from_circuit( + circuit: QuantumCircuit, + remove_zero_gates=True, + remove_small_gates=False, + gate_range=None, +): + """ + Remove unnecessary gates from circuit by merging adjacent gates of same + kind, converting 3+ consecutive single + qubit gates on a single qubit to an rzryrz decomposition, removing + similar consecutive cx, cz gates. + :param circuit: Circuit from which gates are to be removed + :param remove_zero_gates: If true, single qubit gates with 0 angle will + be removed + :param remove_small_gates: If true, single qubit gates with angle less + than MINIMUM_ROTATION_ANGLE will be removed + :param gate_range: If provided, only gates in that range relative to + circuit.data will be modified + """ + if gate_range is None: + gate_range = [0, len(circuit.data)] + else: + gate_range = list(gate_range) + + last_circuit_length = len(circuit.data) + i = 0 + while True: + if i == 0: + remove_unnecessary_1q_gates_from_circuit( + circuit, remove_zero_gates, remove_small_gates, gate_range + ) + i = 1 + else: + remove_unnecessary_2q_gates_from_circuit(circuit, gate_range) + i = 0 + new_circuit_length = len(circuit.data) + if new_circuit_length != last_circuit_length: + # Update the gate range maximum to account for the shortened + # circuit + gate_range[1] -= last_circuit_length - new_circuit_length + last_circuit_length = new_circuit_length + elif i == 0: + return + + +def remove_unnecessary_1q_gates_from_circuit( + circuit, + remove_zero_gates=True, + remove_small_gates=False, + gate_range=None, + min_rotation_angle=MINIMUM_ROTATION_ANGLE, +): + """ + Remove unnecessary 1-qubit gates from circuit by converting 3+ + consecutive single qubit gates on a single qubit + to an rzryrz decomposition + :param circuit: Circuit from which gates are to be removed + :param remove_zero_gates: If true, single qubit gates with 0 angle will + be removed + :param remove_small_gates: If true, single qubit gates with angle less + than MINIMUM_ROTATION_ANGLE will be removed + :param gate_range: If provided, only gates in that range relative to + circuit.data will be modified + (lower index is inclusive and upper index is exclusive) + :param min_rotation_angle: If remove_small_gates, rotation gates + with angles smaller than min_rotation_angle will be removed + """ + if gate_range is None: + gate_range = (0, len(circuit.data)) + + indexes_to_remove = [] + indexes_dealt_with = [] + + # Reverse iterate over all gates + for gate_index in range(gate_range[1] - 1, gate_range[0] - 1, -1): + gate = circuit.data[gate_index].operation + if ( + gate_index in indexes_to_remove + or gate_index in indexes_dealt_with + or not is_supported_1q_gate(gate) + ): + continue + remove_because_zero = remove_zero_gates and gate.params[0] == 0 + remove_because_small = ( + remove_small_gates and np.absolute(gate.params[0]) < min_rotation_angle + ) + if remove_because_zero or remove_because_small: + indexes_to_remove += [gate_index] + continue + + # Any single qubit operation can be reduced to phase * Rz(phi) * Ry( + # theta) * Rz(lambda) + # RXGate, RYGate, RZGate do not implement to_matrix() but their + # definitions (U3Gate or U1Gate) do + matrix = circuit.data[gate_index].operation.to_matrix() + prev_gate_indexes = [gate_index] + prev_gate, prev_gate_index = find_previous_gate_on_qubit(circuit, gate_index) + + # Get all previous gates on qubit (until end or non rx/rz/rz gate is + # met) + while ( + prev_gate is not None + and is_supported_1q_gate(prev_gate) + and prev_gate_index >= gate_range[0] + ): + # If that gate is small, add it to indexes_to_remove and do not + # add it in decomposition + remove_because_zero = remove_zero_gates and prev_gate.params[0] == 0 + remove_because_small = ( + remove_small_gates + and np.absolute(prev_gate.params[0]) < min_rotation_angle + ) + if remove_because_zero or remove_because_small: + indexes_to_remove += [prev_gate_index] + else: + prev_gate_indexes += [prev_gate_index] + prev_gate_matrix = circuit.data[prev_gate_index].operation.to_matrix() + matrix = np.matmul(matrix, prev_gate_matrix) + prev_gate, prev_gate_index = find_previous_gate_on_qubit( + circuit, prev_gate_index + ) + + if len(prev_gate_indexes) > 3: + theta, phi, lam = OneQubitEulerDecomposer().angles(matrix) + replace_1q_gate(circuit, prev_gate_indexes[0], "rz", phi) + replace_1q_gate(circuit, prev_gate_indexes[1], "ry", theta) + replace_1q_gate(circuit, prev_gate_indexes[2], "rz", lam) + # replace_1q_gate(circuit, prev_gate_indexes[3], 'ph', phase) + indexes_dealt_with += [prev_gate_indexes[1], prev_gate_indexes[2]] + indexes_to_remove += prev_gate_indexes[3:] + else: + indexes_dealt_with += prev_gate_indexes + for index in sorted(indexes_to_remove, reverse=True): + del circuit.data[index] + + +def remove_unnecessary_2q_gates_from_circuit(circuit, gate_range=None): + """ + Remove unnecessary 2-qubit gates from circuit by removing pairs of + consecutive CX/CZ gates + :param circuit: Circuit from which gates are to be removed + :param gate_range: If provided, only gates in that range relative to + circuit.data will be modified + (lower index is inclusive and upper index is exclusive) + """ + if gate_range is None: + gate_range = (0, len(circuit.data)) + + indexes_to_remove = [] + indexes_dealt_with = [] + + # Reverse iterate over all gates + for gate_index in range(gate_range[1] - 1, gate_range[0] - 1, -1): + circ_instr = circuit.data[gate_index] + gate = circ_instr.operation + qargs = circ_instr.qubits + if gate.name not in ["cx", "cy", "cz"]: + continue + if gate_index in indexes_to_remove or gate_index in indexes_dealt_with: + continue + prev_gate, prev_gate_index = find_previous_gate_on_qubit(circuit, gate_index) + if prev_gate is None or prev_gate.name != gate.name: + continue + if prev_gate_index < gate_range[0]: + continue + if ( + prev_gate_index in indexes_to_remove + or prev_gate_index in indexes_dealt_with + ): + continue + if circuit.data[prev_gate_index].qubits == qargs: + indexes_to_remove += [gate_index, prev_gate_index] + for index in sorted(indexes_to_remove, reverse=True): + del circuit.data[index] + + +def advanced_circuit_transpilation( + circuit, + c_map, + optimization_level=2, + basis_gates=["cx", "rx", "ry", "rz"], +): + """ + Advanced circuit transpilation with chosen optimization_level. + :param circuit: Circuit to transpile + :param c_map: Directed coupling map for qiskit transpiler to target in mapping. + :param optimization_level: Order of optimization for transpiler to apply + Generally indicates how aggressively circuit transpilation will be done + Default = 2 + :param basis_gates: Basis gates to transpile to. + Default = ["cx", "rx", "ry", "rz"] + """ + return transpile( + circuit, + basis_gates=basis_gates, + coupling_map=convert_cmap_to_qiskit_format(c_map), + # ensure qubits are not re-ordered + layout_method="trivial", + initial_layout=get_initial_layout(circuit), + optimization_level=optimization_level, + ) diff --git a/utils/adapt-aqc/adaptaqc/utils/circuit_operations/circuit_operations_pauli_ops.py b/utils/adapt-aqc/adaptaqc/utils/circuit_operations/circuit_operations_pauli_ops.py new file mode 100644 index 0000000000000000000000000000000000000000..dfc116c9bb0197eb21eba47b1b9b145307276c29 --- /dev/null +++ b/utils/adapt-aqc/adaptaqc/utils/circuit_operations/circuit_operations_pauli_ops.py @@ -0,0 +1,127 @@ +# (C) Copyright IBM 2025. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +import numpy as np +from openfermion import QubitOperator +from qiskit import ClassicalRegister, QuantumCircuit +from qiskit.circuit.library import UGate, PhaseGate +from qiskit.quantum_info import Pauli + +from adaptaqc.utils.circuit_operations.circuit_operations_full_circuit import ( + add_classical_operations, + add_to_circuit, + remove_classical_operations, + remove_inner_circuit, +) +from adaptaqc.utils.circuit_operations.circuit_operations_running import ( + run_circuit_without_transpilation, +) +from adaptaqc.utils.utilityfunctions import ( + expectation_value_of_pauli_observable, + is_statevector_backend, +) + + +def add_pauli_operators_to_circuit( + circuit: QuantumCircuit, pauli: Pauli, location=None +): + if location is None: + location = len(circuit.data) + original_circuit_length = len(circuit.data) + # Add rotation gates + pauli_circuit = QuantumCircuit(circuit.num_qubits) + for i, pauli_axis in enumerate(reversed(pauli.to_label())): + if pauli_axis == "I": + continue + elif pauli_axis == "X": + UGate(np.pi, -0.5 * np.pi, 0.5 * np.pi) + elif pauli_axis == "Y": + UGate(np.pi, 0, 0) + elif pauli_axis == "Z": + PhaseGate(np.pi) + else: + raise ValueError(f"Unexpected pauli axis {pauli_axis}") + + # Add post rotation gates (copied from pauli_measurement in + # qiskit.aqua.operators.common) + for qubit_idx in range(circuit.num_qubits): + if pauli.x[qubit_idx]: + if pauli.z[qubit_idx]: + # Measure Y + pauli_circuit.p(-np.pi / 2, qubit_idx) # sdg + pauli_circuit.u(np.pi / 2, 0.0, np.pi, qubit_idx) # h + else: + # Measure X + pauli_circuit.u(np.pi / 2, 0.0, np.pi, qubit_idx) # h + add_to_circuit( + circuit, pauli_circuit, location=location, transpile_before_adding=False + ) + pauli_circuit_len = len(circuit.data) - original_circuit_length + pauli_operators_gate_range = (location, location + pauli_circuit_len) + return pauli_operators_gate_range + + +def expectation_value_of_pauli_operator( + circuit: QuantumCircuit, + operator: dict, + backend, + backend_options=None, + execute_kwargs=None, +): + expectation_value = 0 + cl_ops_data = remove_classical_operations(circuit) + creg = ClassicalRegister(circuit.num_qubits) + circuit.add_register(creg) + for pauli_lbl in operator.keys(): + if pauli_lbl == "I" * len(pauli_lbl): + expectation_value += operator[pauli_lbl] * 1 + continue + pauli_obj = Pauli(pauli_lbl) + pauli_circuit_gate_range = add_pauli_operators_to_circuit(circuit, pauli_obj) + if not is_statevector_backend(backend): + [ + circuit.measure(circuit.qregs[0][x], creg[x]) + for x in range(circuit.num_qubits) + ] + counts = run_circuit_without_transpilation( + circuit, backend, backend_options, execute_kwargs + ) + remove_classical_operations(circuit) + eval_po = expectation_value_of_pauli_observable(counts, pauli_obj) + expectation_value += operator[pauli_lbl] * eval_po + + remove_inner_circuit(circuit, pauli_circuit_gate_range) + circuit.cregs.remove(creg) + add_classical_operations(circuit, cl_ops_data) + return expectation_value + + +def convert_qubit_op_to_pauli_dict(qubit_op: QubitOperator): + paulis = [] + base_pauli = ["I"] + for action_pairs, coeff in qubit_op.terms.items(): + if not np.isreal(coeff): + raise ValueError("Complex coefficients unsupported") + else: + coeff = np.real(coeff) + this_pauli = list(base_pauli) + for qubit_index, pauli_op in action_pairs: + if qubit_index >= len(base_pauli): + # Add extra ops to all pauli strings + diff = (qubit_index + 1) - len(base_pauli) + base_pauli += ["I"] * diff + this_pauli += ["I"] * diff + for key in [x[0] for x in paulis]: + key += ["I"] * diff + this_pauli[qubit_index] = pauli_op + paulis.append((this_pauli, coeff)) + + pauli_dict = {"".join(pauli_list[::-1]): coeff for (pauli_list, coeff) in paulis} + return pauli_dict diff --git a/utils/adapt-aqc/adaptaqc/utils/circuit_operations/circuit_operations_running.py b/utils/adapt-aqc/adaptaqc/utils/circuit_operations/circuit_operations_running.py new file mode 100644 index 0000000000000000000000000000000000000000..1f7634d8305aaacad590b56fff9081518655977a --- /dev/null +++ b/utils/adapt-aqc/adaptaqc/utils/circuit_operations/circuit_operations_running.py @@ -0,0 +1,139 @@ +# (C) Copyright IBM 2025. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +import logging + +import numpy as np +from qiskit import QuantumCircuit, transpile +from qiskit.circuit.library import CXGate +from qiskit_aer.backends.aerbackend import AerBackend +from qiskit_aer.noise import thermal_relaxation_error, NoiseModel +from scipy.optimize import curve_fit + +from adaptaqc.backends.aer_sv_backend import AerSVBackend +from adaptaqc.backends.python_default_backends import QASM_SIM +from adaptaqc.backends.qiskit_sampling_backend import QiskitSamplingBackend +from adaptaqc.utils.utilityfunctions import ( + counts_data_from_statevector, + is_statevector_backend, +) + +logger = logging.getLogger(__name__) + + +def run_circuit_with_transpilation( + circuit: QuantumCircuit, + backend=QASM_SIM, + backend_options=None, + execute_kwargs=None, + return_statevector=False, +): + transpiled_circuit = transpile(circuit, backend.simulator) + return run_circuit_without_transpilation( + transpiled_circuit, backend, backend_options, execute_kwargs, return_statevector + ) + + +def run_circuit_without_transpilation( + circuit: QuantumCircuit, + backend: QiskitSamplingBackend | AerSVBackend = QASM_SIM, + backend_options=None, + execute_kwargs=None, + return_statevector=False, +): + if execute_kwargs is None: + execute_kwargs = {} + + # Backend options only supported for simulators + if backend_options is None or not isinstance(backend, AerBackend): + backend_options = {} + # executing the circuits on the backend and returning the job + job = backend.simulator.run(circuit, **backend_options, **execute_kwargs) + + result = job.result() + if is_statevector_backend(backend): + if return_statevector: + output = result.get_statevector() + else: + output = counts_data_from_statevector(result.get_statevector()) + else: + output = result.get_counts() + + return output + + +def create_noisemodel(t1, t2, log_fidelities=True): + # Instruction times (in nanoseconds) + time_u1 = 0 # virtual gate + time_u2 = 50 # (single X90 pulse) + time_u3 = 100 # (two X90 pulses) + time_cx = 300 + time_reset = 1000 # 1 microsecond + time_measure = 1000 # 1 microsecond + + t1 = t1 * 1e6 + t2 = t2 * 1e6 + + # QuantumError objects + error_reset = thermal_relaxation_error(t1, t2, time_reset) + error_measure = thermal_relaxation_error(t1, t2, time_measure) + error_u1 = thermal_relaxation_error(t1, t2, time_u1) + error_u2 = thermal_relaxation_error(t1, t2, time_u2) + error_u3 = thermal_relaxation_error(t1, t2, time_u3) + error_cx = thermal_relaxation_error(t1, t2, time_cx).expand( + thermal_relaxation_error(t1, t2, time_cx) + ) + + # Add errors to noise model + noise_thermal = NoiseModel() + noise_thermal.add_all_qubit_quantum_error(error_reset, "reset") + noise_thermal.add_all_qubit_quantum_error(error_measure, "measure") + noise_thermal.add_all_qubit_quantum_error(error_u1, "u1") + noise_thermal.add_all_qubit_quantum_error(error_u2, "u2") + noise_thermal.add_all_qubit_quantum_error(error_u3, "u3") + noise_thermal.add_all_qubit_quantum_error(error_cx, "cx") + + if log_fidelities: + logger.info("Noise model fidelities:") + for qubit_error in noise_thermal.to_dict()["errors"]: + logging.info( + f"{qubit_error['operations']}: " f"{max(qubit_error['probabilities'])}" + ) + return noise_thermal + + +def zero_noise_extrapolate( + circuit: QuantumCircuit, measurement_function, num_points=10 +): + calculated_values = [] + probabilities = np.linspace(0, 1, num_points) + for prob in probabilities: + circuit_data_copy = circuit.data.copy() + for i, (gate, qargs, cargs) in list(enumerate(circuit.data))[::-1]: + if isinstance(gate, CXGate): + if np.random.random() < prob: + circuit.data.insert(i, (gate, qargs, cargs)) + circuit.data.insert(i, (gate, qargs, cargs)) + + calculated_values.append(measurement_function()) + circuit.data = circuit_data_copy + + def exp_decay(x, intercept, amp, decay_rate): + return intercept + amp * np.exp(-1 * x / decay_rate) + + try: + popt, pcov = curve_fit( + exp_decay, probabilities, calculated_values, [0, calculated_values[0], 1] + ) + zne_val = exp_decay(-0.5, *popt) + return zne_val + except RuntimeError as e: + logger.warning(f"Failed to zero-noise-extrapolate. Error was {e}") + return measurement_function() diff --git a/utils/adapt-aqc/adaptaqc/utils/circuit_operations/circuit_operations_variational.py b/utils/adapt-aqc/adaptaqc/utils/circuit_operations/circuit_operations_variational.py new file mode 100644 index 0000000000000000000000000000000000000000..0d127df36af08a338ce4fffee7f7b85462cb1d01 --- /dev/null +++ b/utils/adapt-aqc/adaptaqc/utils/circuit_operations/circuit_operations_variational.py @@ -0,0 +1,84 @@ +# (C) Copyright IBM 2025. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +import numpy as np +from qiskit import QuantumCircuit +from qiskit.circuit import CircuitInstruction +from qiskit.circuit.library import U3Gate, U1Gate + +from adaptaqc.utils.circuit_operations.circuit_operations_basic import ( + is_supported_1q_gate, +) +from adaptaqc.utils.utilityfunctions import normalized_angles + + +def find_angles_in_circuit(circuit, gate_range=None): + """ + Find the angles of rotation gates in the circuit. Ordering is relative + to position of gate in circuit.data. + Ignores gates that have the label FIXED_GATE_LABEL + :param circuit: QuantumCircuit + :param gate_range: The search space for the angles (full circuit if None) + :return: Angles in circuit (list) + """ + angles = [] + if gate_range is None: + gate_range = (0, len(circuit.data)) + angle_index = 0 + for gate_index in range(*gate_range): + gate = circuit.data[gate_index].operation + if is_supported_1q_gate(gate): + # Normalize angle to between -pi and pi + angles += [normalized_angles(gate.params[0])] + angle_index += 1 + return angles + + +def update_angles_in_circuit(circuit: QuantumCircuit, angles, gate_range=None): + """ + Changes the angle of all rotation gates in the circuit except those with + label = FIXED_GATE_LABEL + :param circuit: Circuit to modify + :param angles: New angles (list/np.ndarray) + :param gate_range: The range of gates in which the 1q gates are located + for the angles (full circuit if None) + """ + if gate_range is None: + gate_range = (0, len(circuit.data)) + angle_index = 0 + for gate_index in range(*gate_range): + circ_instr = circuit.data[gate_index] + gate = circ_instr.operation + if is_supported_1q_gate(gate): + gate.params[0] = angles[angle_index] + angle_index += 1 + circuit.data[gate_index] = circ_instr + + +def create_variational_circuit(circuit: QuantumCircuit): + new_circ = QuantumCircuit(*circuit.qregs, *circuit.cregs) + + for circ_instr in circuit.data: + gate = circ_instr.operation + if isinstance(gate, U1Gate): + gate.label = "rz" + elif isinstance(gate, U3Gate): + if gate.params[1] == gate.params[2] and gate.params[2] == 0: + gate.label = "ry" + elif np.isclose(-0.5 * np.pi, gate.params[1]) and np.isclose( + 0.5 * np.pi, gate.params[2] + ): + gate.label = "rx" + new_circ.data.append( + CircuitInstruction( + operation=gate, qubits=circ_instr.qubits, clbits=circ_instr.clbits + ) + ) + return new_circ diff --git a/utils/adapt-aqc/adaptaqc/utils/constants.py b/utils/adapt-aqc/adaptaqc/utils/constants.py new file mode 100644 index 0000000000000000000000000000000000000000..0559bcbca476b5a371de80edd0311afcc1f1c374 --- /dev/null +++ b/utils/adapt-aqc/adaptaqc/utils/constants.py @@ -0,0 +1,131 @@ +# (C) Copyright IBM 2025. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +"""Contains constants""" +from typing import List, Tuple + +import numpy as np + +# Type of MPS data as it outputted by Qiskit. +QiskitMPS = Tuple[List[Tuple[np.ndarray, np.ndarray]], List[np.ndarray]] + +ALG_ROTOSOLVE = "rotosolve" +ALG_ROTOSELECT = "rotoselect" +ALG_NLOPT = "nlopt" +ALG_SCIPY = "scipy" +ALG_PYBOBYQA = "pybobyqa" + +FIXED_GATE_LABEL = "fixed_gate" + +CMAP_FULL = "CMAP_FULL" +CMAP_LINEAR = "CMAP_LINEAR" +CMAP_LADDER = "CMAP_LADDER" + +DEFAULT_SUFFICIENT_COST = 1e-2 + + +def generate_coupling_map(num_qubits, map_kind, both_dir=False, loop=False): + if map_kind == CMAP_FULL: + return coupling_map_fully_entangled(num_qubits, both_dir) + elif map_kind == CMAP_LINEAR: + return coupling_map_linear(num_qubits, both_dir, loop) + elif map_kind == CMAP_LADDER: + return coupling_map_ladder(num_qubits, both_dir, loop) + else: + raise ValueError(f"Invalid coupling map type {map_kind}") + + +def coupling_map_fully_entangled(num_qubits, both_dir=False): + """ + Coupling map with all qubits connected to each other + :param num_qubits: Number of qubits + :param both_dir: If true, map will include gates with control and target + swapped + :return: [(control(int),target(int))] + """ + c_map = [] + for i in range(1, num_qubits): + for j in range(num_qubits - i): + c_map.append((j, j + i)) + if both_dir: + c_map_rev = [(target, source) for (source, target) in c_map] + c_map += c_map_rev + return c_map + + +def coupling_map_linear(num_qubits, both_dir=False, loop=False): + """ + Coupling map with qubits connected to adjacent qubits + :param num_qubits: Number of qubits + :param both_dir: If true, map will include gates with control and target + swapped + :param loop: If true, the first qubit will be connected to the last + qubit as well + :return: [(control(int),target(int))] + """ + c_map = [] + for j in range(num_qubits - 1): + c_map.append((j, j + 1)) + if loop: + c_map.append((num_qubits - 1, 0)) + if both_dir: + c_map_rev = [(target, source) for (source, target) in c_map] + c_map += c_map_rev + return c_map + + +def coupling_map_ladder(num_qubits, both_dir=False, loop=False): + """ + Low depth coupling map with qubits connected to adjacent qubits + :param num_qubits: Number of qubits + :param both_dir: If true, map will include gates with control and target + swapped + :param loop: If true, the first qubit will be connected to the last + qubit as well + :return: [(control(int),target(int))] + """ + c_map = [] + j = 0 + while j + 1 <= num_qubits - 1: + c_map.append((j, j + 1)) + j += 2 + j = 1 + if loop and num_qubits % 2 == 1: + c_map.append((num_qubits - 1, 0)) + while j + 1 <= num_qubits - 1: + c_map.append((j, j + 1)) + j += 2 + if loop and num_qubits % 2 == 0: + c_map.append((num_qubits - 1, 0)) + if both_dir: + c_map_rev = [(target, source) for (source, target) in c_map] + c_map += c_map_rev + return c_map + + +def convert_cmap_to_qiskit_format(c_map): + """ + Convert a list of tuples to a list of lists that qiskit expects for transpiling with a c_map. + :param c_map: List of tuples [(int, int)] + :return: List of lists [[int, int]] + """ + return [list(pair) for pair in c_map] + + +def get_initial_layout(circuit): + """ + Extracts initial layout of a circuit. + + :param circuit: The original circuit to determine the layout for. + :return: Dictionary for initial_layout in the form {logical_qubit: physical_qubit} + """ + # map logical qubits to their indices in the circuit + initial_layout = {qubit: idx for idx, qubit in enumerate(circuit.qubits)} + return initial_layout diff --git a/utils/adapt-aqc/adaptaqc/utils/cost_minimiser.py b/utils/adapt-aqc/adaptaqc/utils/cost_minimiser.py new file mode 100644 index 0000000000000000000000000000000000000000..59c41b63a2e38c33f3dbf13d96a9f021c0174bf6 --- /dev/null +++ b/utils/adapt-aqc/adaptaqc/utils/cost_minimiser.py @@ -0,0 +1,418 @@ +# (C) Copyright IBM 2025. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +"""Contains CostMinimiser""" +import logging +import random +from typing import Tuple + +import numpy as np +from scipy.optimize import minimize + +import adaptaqc.utils.circuit_operations as co +import adaptaqc.utils.constants as vconstants +from adaptaqc.utils.circuit_operations import SUPPORTED_1Q_GATES +from adaptaqc.utils.utilityfunctions import ( + derivative_of_sinusoidal, + has_stopped_improving, + minimum_of_sinusoidal, + find_rotation_indices, +) + +logger = logging.getLogger(__name__) + + +class CostMinimiser: + """ + Minimizer that minimizes a cost function + """ + + def __init__( + self, + cost_finder, + variational_circuit_range, + full_circuit, + rotosolve_fraction=1.0, + ): + """ + :param cost_finder: Callable that returns cost(float) + """ + self.cost_finder = cost_finder + self.variational_circuit_range = variational_circuit_range + self.full_circuit = full_circuit + self.rotosolve_fraction = rotosolve_fraction + + def minimize_cost( + self, + algorithm_kind=vconstants.ALG_ROTOSOLVE, + algorithm_identifier=None, + max_cycles=1000, + stop_val=-np.inf, + tol=1e-10, + indexes_to_modify=None, + alg_kwargs=None, + ): + """ + Minimize the cost by varying rotation gate angles (and axes in case + of ALG_ROTOSELECT). Gates with label + FIXED_GATE_LABEL will not be varied. + :param algorithm_kind: + :param algorithm_identifier: + :param max_cycles: For ALG_ROTOSOLVE,ALG_ROTOSELECT, this is the max + number of cycles + :param stop_val: Minimization will stop when this value is reached + :param tol: Tolerance (float). Difference algorithms have different + implementations of this value + :param indexes_to_modify: If not None, only gates with the given + indexes (index of gate in variational_circuit.data) will be varied + (only valid for rotosolve/rotoselect) + :param alg_kwargs: Keyword arguments supplied to particular optimiser + :return: + """ + if alg_kwargs is None: + alg_kwargs = {} + if ( + algorithm_kind == vconstants.ALG_ROTOSOLVE + or algorithm_kind == vconstants.ALG_ROTOSELECT + ): + if algorithm_kind == vconstants.ALG_ROTOSOLVE: + alg_name = "ROTOSOLVE" + else: + alg_name = "ROTOSELECT" + + cost_history = [] + cost = self.cost_finder() + cycles = 0 + logger.info(f"Starting {alg_name}") + while cost > stop_val and cycles < max_cycles: + cost = self._reduce_cost( + algorithm_kind == vconstants.ALG_ROTOSELECT, indexes_to_modify + ) + cycles += 1 + logger.info(f"{alg_name} cycle: {cycles}") + cost_history.append(cost) + if len(cost_history) > 3 and has_stopped_improving( + cost_history[-3:], tol + ): + break + logger.info(f"{alg_name} finished with cost {cost}") + return cost + + elif algorithm_kind == vconstants.ALG_NLOPT: + try: + import nlopt + except ModuleNotFoundError as e: + logger.error( + "NLOPT not installed. Use 'conda install -c conda-forge " + "nlopt' to install nlopt using conda" + ) + raise e + initial_angles = co.find_angles_in_circuit( + self.full_circuit, self.variational_circuit_range() + ) + if len(initial_angles) == 0: + return self.cost_finder() + # Setup optimizer + opt = nlopt.opt(algorithm_identifier, len(initial_angles)) + opt.set_upper_bounds([np.pi] * len(initial_angles)) + opt.set_lower_bounds([-np.pi] * len(initial_angles)) + opt.set_stopval(stop_val) + opt.set_ftol_rel(tol) + opt.set_xtol_abs(1e-10) + opt.set_min_objective(self._find_cost_with_angles) + + # Start optimization + try: + final_angles = opt.optimize(initial_angles) + except RuntimeError as e: + logger.error(f"Nlopt optimisation failed") + raise e + + co.update_angles_in_circuit( + self.full_circuit, final_angles, self.variational_circuit_range() + ) + return opt.last_optimum_value() + + elif algorithm_kind == vconstants.ALG_SCIPY: + initial_angles = co.find_angles_in_circuit( + self.full_circuit, self.variational_circuit_range() + ) + optimization_result = minimize( + fun=self._find_cost_with_angles, + method=algorithm_identifier, + x0=initial_angles, + tol=tol, + **alg_kwargs, + ) + co.update_angles_in_circuit( + self.full_circuit, + optimization_result["x"], + self.variational_circuit_range(), + ) + return optimization_result["fun"] + elif algorithm_kind == vconstants.ALG_PYBOBYQA: + try: + import pybobyqa + except ModuleNotFoundError as e: + logger.error( + "PyBOBYQA not installed. Use 'pip install Py-BOBYQA' " + "to install using pip" + ) + raise e + + initial_angles = co.find_angles_in_circuit( + self.full_circuit, self.variational_circuit_range() + ) + bounds = ([-np.pi] * len(initial_angles), [np.pi] * len(initial_angles)) + try: + result = pybobyqa.solve( + self._find_cost_with_angles, + initial_angles, + bounds=bounds, + objfun_has_noise=True, + print_progress=False, + do_logging=False, + **alg_kwargs, + ) + co.update_angles_in_circuit( + self.full_circuit, result.x, self.variational_circuit_range() + ) + return result.f + except Exception as e: + logger.error(f"BOBYQA failed with exception: {e}") + co.update_angles_in_circuit( + self.full_circuit, initial_angles, self.variational_circuit_range() + ) + return self.cost_finder() + else: + raise ValueError(f"Invalid algorithm kind {algorithm_kind}") + + def try_escaping_periodic_local_minimum( + self, gap_between_minima, first_minima_loc, penalty_amp=0.1 + ): + initial_cost = self.cost_finder() + initial_angles = co.find_angles_in_circuit( + self.full_circuit, self.variational_circuit_range() + ) + num_attempts = 5 + stochastic_param = 1 + + def find_cost_with_penalty_for_angles(angles, grad=None): + cost = self._find_cost_with_angles(angles, grad) + # Create a sinusoidally varying potential that has maxima at the + # local minima locations + penalty = penalty_amp * np.cos( + np.pi + + ( + (cost - first_minima_loc) + * 2 + * np.pi + * (1 / gap_between_minima) + * stochastic_param + ) + ) + return cost + penalty + + actual_cost = initial_cost + for i in range(num_attempts): + res = minimize( + find_cost_with_penalty_for_angles, initial_angles, method="Nelder-Mead" + ) + final_angles = res.x + + co.update_angles_in_circuit( + self.full_circuit, final_angles, self.variational_circuit_range() + ) + cost_with_penalty = res.fun + + co.update_angles_in_circuit( + self.full_circuit, final_angles, self.variational_circuit_range() + ) + actual_cost = self.cost_finder() + logging.debug( + f"{i}th Attempt to escape minima: initial cost = " + f"{initial_cost}, final cost with penalty " + f"= {cost_with_penalty}, " + f"actual final cost = {actual_cost}" + ) + stochastic_param = np.random.random() * 10 + if actual_cost < initial_cost: + break + return actual_cost + + def _find_cost_with_angles(self, angles, grad=None): + """ + Find the cost with self.full_circuit with the given angles. + This method changes the angles of self.full_circuit + :param angles: New angles + :param grad: Gradient of circuit (used by gradient-based optimizers) + which is modified in place + :return: Cost (float) + """ + co.update_angles_in_circuit( + self.full_circuit, angles, self.variational_circuit_range() + ) + if grad is not None and grad.size > 0: + self._update_gradient_of_circuit(grad) + cost = self.cost_finder() + return cost + + def _reduce_cost( + self, + change_1q_gate_kind=False, + indexes_to_modify: Tuple[int, int] = None, + ): + """ + For each gate in the full circuit, find the optimal angle (and gate + kind) while keeping all other gates fixed. + Sequentially cycles over gates w.r.t their index in circuit.data + :param change_1q_gate_kind: If true, the optimal gate kind ( + rx/ry/rz) will be chosen for each gate + :param indexes_to_modify: If not None, all gates except those at + specified indexes will be fixed. + Indexes are relative to full_circuit.data + :return: New cost + """ + cost = 1 + variational_circuit_range = self.variational_circuit_range() + if indexes_to_modify is None: + indexes_to_modify = variational_circuit_range + else: + indexes_to_modify = ( + max(indexes_to_modify[0], variational_circuit_range[0]), + min(indexes_to_modify[1], variational_circuit_range[1]), + ) + + if self.rotosolve_fraction < 1.0 and not change_1q_gate_kind: + indexes_to_modify_list = list(range(*indexes_to_modify)) + indexes_to_modify_list = find_rotation_indices( + self.full_circuit, indexes_to_modify_list + ) + num_to_sample = int( + np.ceil(self.rotosolve_fraction * len(indexes_to_modify_list)) + ) + sample = random.sample(indexes_to_modify_list, num_to_sample) + sample.sort() + else: + sample = list(range(*indexes_to_modify)) + + for index in sample: + old_gate = self.full_circuit.data[index].operation + + if change_1q_gate_kind and co.is_supported_1q_gate(old_gate): + cost = self.replace_with_best_1q_gate(index) + elif co.is_supported_1q_gate(old_gate): + angle, cost = self.find_best_angle(index, old_gate.label) + co.replace_1q_gate(self.full_circuit, index, old_gate.label, angle) + else: + continue + return cost + + def replace_with_best_1q_gate(self, gate_index): + """ + Find the gate which results in the lowest cost and replace the gate + at gate_index with the best gate + :param gate_index: The index of the gate that is to be replaced + :return: New cost + """ + # Find cost at 0 angle separately because it is the same regardless + # of gate kind + co.replace_1q_gate(self.full_circuit, gate_index, "rx", 0) + cost_identity = self.cost_finder() + best_gate_name, best_gate_angle, best_gate_cost = None, None, 1 + # TODO Could this loop be parallelised? + for gate_name in SUPPORTED_1Q_GATES: + min_angle, cost = self.find_best_angle(gate_index, gate_name, cost_identity) + if cost < best_gate_cost: + best_gate_name, best_gate_angle, best_gate_cost = ( + gate_name, + min_angle, + cost, + ) + co.replace_1q_gate( + self.full_circuit, gate_index, best_gate_name, best_gate_angle + ) + return best_gate_cost + + def find_best_angle(self, gate_index, gate_name, cost_for_identity=None): + """ + Find the angle of the specified gate which results in the lowest cost + :param gate_index: The index of the gate that is to be checked + :param gate_name: Name of the gate kind that is to be used + :param cost_for_identity: The cost when the angle is 0 + :return: best_gate_angle, best_cost + """ + # Remember original gate + circ_instr = self.full_circuit.data[gate_index] + + costs = [] + angles_to_run = [0, np.pi / 2, -np.pi / 2] + if cost_for_identity is not None: + costs.append(cost_for_identity) + angles_to_run.remove(0) + + for theta in angles_to_run: + co.replace_1q_gate(self.full_circuit, gate_index, gate_name, theta) + costs.append(self.cost_finder()) + theta_min, cost_min = minimum_of_sinusoidal(costs[0], costs[1], costs[2]) + + # Replace with original gate + self.full_circuit.data[gate_index] = circ_instr + return theta_min, cost_min + + def _update_gradient_of_circuit(self, grad, method="parameter_shift"): + """ + Evaluates the gradient of the circuit (list of partial derivatives + of cost w.r.t each rotation angle) + :param grad: Old gradient (modified in place) + """ + angles = co.find_angles_in_circuit(self.full_circuit) + angle_index = 0 + for gate_index in range(*self.variational_circuit_range()): + gate = self.full_circuit.data[gate_index].operation + if co.is_supported_1q_gate(gate): + # Calculate partial derivative + if method == "parameter_shift": + r = 0.5 + shift = np.pi * (1 / (4 * r)) + current_angle = angles[angle_index] + co.replace_1q_gate( + self.full_circuit, gate_index, gate.label, current_angle + shift + ) + value_plus = self.cost_finder() + co.replace_1q_gate( + self.full_circuit, gate_index, gate.label, current_angle - shift + ) + value_minus = self.cost_finder() + + grad[angle_index] = r * (value_plus - value_minus) + + else: + co.replace_1q_gate(self.full_circuit, gate_index, gate.label, 0) + value_0 = self.cost_finder() + co.replace_1q_gate( + self.full_circuit, gate_index, gate.label, np.pi / 2 + ) + value_pi_by_2 = self.cost_finder() + co.replace_1q_gate( + self.full_circuit, gate_index, gate.label, -np.pi / 2 + ) + value_minus_pi_by_2 = self.cost_finder() + + grad[angle_index] = derivative_of_sinusoidal( + angles[angle_index], value_0, value_pi_by_2, value_minus_pi_by_2 + ) + + # Return circuit back to original + co.replace_1q_gate( + self.full_circuit, gate_index, gate.label, angles[angle_index] + ) + + angle_index += 1 diff --git a/utils/adapt-aqc/adaptaqc/utils/entanglement_measures.py b/utils/adapt-aqc/adaptaqc/utils/entanglement_measures.py new file mode 100644 index 0000000000000000000000000000000000000000..8e80722f83b5947e86558ee61f8b540ccabe7e3e --- /dev/null +++ b/utils/adapt-aqc/adaptaqc/utils/entanglement_measures.py @@ -0,0 +1,370 @@ +# (C) Copyright IBM 2025. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +"""Contains functions to measure quantum correlations""" +import copy +import itertools +import logging + +import aqc_research.mps_operations as mpsops +import numpy as np +from qiskit import ClassicalRegister, QuantumCircuit, QuantumRegister +from qiskit import quantum_info as qi +from qiskit_aer.backends.aerbackend import AerBackend +from qiskit_experiments.library import StateTomography +from scipy import linalg +from scipy.linalg import eig + +import adaptaqc.utils.circuit_operations as co +from adaptaqc.backends.aer_mps_backend import AerMPSBackend +from adaptaqc.backends.aqc_backend import AQCBackend +from adaptaqc.utils.utilityfunctions import is_statevector_backend + +logger = logging.getLogger(__name__) + +EM_OBSERVABLE_CONCURRENCE_LOWER_BOUND = "EM_OBSERVABLE_CONCURRENCE_LOWER_BOUND" +EM_TOMOGRAPHY_EOF = "EM_TOMOGRAPHY_EOF" +EM_TOMOGRAPHY_CONCURRENCE = "EM_TOMOGRAPHY_CONCURRENCE" +EM_TOMOGRAPHY_NEGATIVITY = "EM_TOMOGRAPHY_NEGATIVITY" +EM_TOMOGRAPHY_LOG_NEGATIVITY = "EM_TOMOGRAPHY_LOG_NEGATIVITY" + + +def calculate_entanglement_measure( + method, + circuit, + qubit_1, + qubit_2, + backend: AQCBackend, + backend_options=None, + execute_kwargs=None, + mps=None, +): + """ + Measure quantum correlations between two qubits in a state resulting + from running a given + QuantumCircuit + :param method: Which entanglement measure/method to use + :param circuit: QuantumCircuit + :param qubit_1: Index of first qubit + :param qubit_2: Index of second qubit + :param backend: Backend on which circuits are to be run. Not relevant + for tomography based + entanglement measures. + Note that observable based entanglement measures can't run on + statevector_simulators + :param backend_options: + :param execute_kwargs: + :return: Value of quantum correlation + """ + if method == EM_OBSERVABLE_CONCURRENCE_LOWER_BOUND: + return measure_concurrence_lower_bound( + circuit, qubit_1, qubit_2, backend, backend_options, execute_kwargs + ) + else: + if is_statevector_backend(backend): + statevector = co.run_circuit_without_transpilation( + circuit, backend, return_statevector=True + ) + rho = partial_trace(statevector, qubit_1, qubit_2) + elif isinstance(backend, AerMPSBackend): + rho = mpsops.partial_trace( + mps, [qubit_1, qubit_2], already_preprocessed=True + ) + else: + rho = perform_quantum_tomography( + circuit, + qubit_1, + qubit_2, + backend.simulator, + backend_options, + execute_kwargs, + ) + if method == EM_TOMOGRAPHY_EOF: + return eof(rho) + elif method == EM_TOMOGRAPHY_CONCURRENCE: + return concurrence(rho) + elif method == EM_TOMOGRAPHY_NEGATIVITY: + return negativity(rho) + elif method == EM_TOMOGRAPHY_LOG_NEGATIVITY: + return log_negativity(rho) + else: + raise ValueError("Invalid entanglement measure method") + + +def perform_quantum_tomography( + circuit: QuantumCircuit, + qubit_1, + qubit_2, + backend, + backend_options=None, + execute_kwargs=None, +): + """ + Performs quantum state tomography on the reduced state of qubit_1 and + qubit_2 + :param circuit: + :param qubit_1: + :param qubit_2: + :param backend: + :param backend_options: + :param execute_kwargs: + :return: + """ + execute_kwargs = {} if execute_kwargs is None else execute_kwargs + old_cregs = circuit.cregs.copy() + circuit.cregs = [] + tomography_exp = StateTomography( + circuit, measurement_indices=sorted([qubit_1, qubit_2]) + ) + circuit.cregs = old_cregs + + # Backend options only supported for simulators + if backend_options is None or not isinstance(backend, AerBackend): + backend_options = {} + + tomography_data = tomography_exp.run(backend, **execute_kwargs) + rho = tomography_data.analysis_results("state").value._data + assert isinstance(rho, np.ndarray) + return rho + + +def measure_concurrence_lower_bound( + circuit: QuantumCircuit, + qubit_1, + qubit_2, + backend, + backend_options=None, + execute_kwargs=None, +): + """ + Measures the lower limit of the concurrence of the mixed, bipartite + state resulting from a + partial trace over all + qubits except those in qubit_pair + Lower bound based on 10.1103/PhysRevLett.98.140505 and upper bound based on + 10.1103/PhysRevA.78.042308 + Concurrence C bounds: K_1,K_2>= C^2 >= V_1,V_2 : + V_1 = 4((P-)-(P+))x(P-) = 4(2(P-)-I)x(P-) = 8(P-)x(P-) - 4(I)x(P-), + V_2 = 4(P-)x((P-)-(P+)) = 4(P-)x(2(P-)-I) = 8(P-)x(P-) - 4(P-)x(I), + K_1 = 4(P-)x(I), + K_2 = 4(I)x(P-), + where (P+) and (P-) are projectors on the symmetric and antisymmetric + subspace + of the two copies of either subsystem. I is the identity operator + :param circuit: QuantumCircuit + :param qubit_1: Index of the first qubit forming the bipartite state + :param qubit_2: Index of the second qubit forming the bipartite state + :param backend: Backend on which circuit is to be run (can't be + statevector_simulator) + :param backend_options: + :param execute_kwargs: + :returns Minimum value of concurrence + """ + # Remove measurements and other classical gates + classical_gates = co.remove_classical_operations(circuit) + num_qubits = circuit.num_qubits + + qc = QuantumCircuit(2 * num_qubits, 4) + co.add_to_circuit(qc, circuit.copy(), qubit_subset=list(range(0, num_qubits))) + co.add_to_circuit( + qc, circuit.copy(), qubit_subset=list(range(num_qubits, 2 * num_qubits)) + ) + + transpile_kwargs = {"backend": backend.simulator} + + p_minus_p_minus_circuit = qc.copy() + co.add_to_circuit( + p_minus_p_minus_circuit, + antisymmetric_subspace_projector_measurement_circuit(), + qubit_subset=[qubit_1, num_qubits + qubit_1], + clbit_subset=[0, 1], + transpile_before_adding=True, + transpile_kwargs=transpile_kwargs, + ) + co.add_to_circuit( + p_minus_p_minus_circuit, + antisymmetric_subspace_projector_measurement_circuit(), + qubit_subset=[qubit_2, num_qubits + qubit_2], + clbit_subset=[2, 3], + transpile_before_adding=True, + transpile_kwargs=transpile_kwargs, + ) + + p_minus_i_circuit = qc.copy() + co.add_to_circuit( + p_minus_i_circuit, + antisymmetric_subspace_projector_measurement_circuit(), + qubit_subset=[qubit_1, num_qubits + qubit_1], + clbit_subset=[0, 1], + transpile_before_adding=True, + transpile_kwargs=transpile_kwargs, + ) + + i_p_minus_circuit = qc.copy() + co.add_to_circuit( + i_p_minus_circuit, + antisymmetric_subspace_projector_measurement_circuit(), + qubit_subset=[qubit_2, num_qubits + qubit_2], + clbit_subset=[2, 3], + transpile_before_adding=True, + transpile_kwargs=transpile_kwargs, + ) + + p_minus_p_minus_counts = co.run_circuit_without_transpilation( + p_minus_p_minus_circuit, backend, backend_options, execute_kwargs + ) + p_minus_i_counts = co.run_circuit_without_transpilation( + p_minus_i_circuit, backend, backend_options, execute_kwargs + ) + i_p_minus_counts = co.run_circuit_without_transpilation( + i_p_minus_circuit, backend, backend_options, execute_kwargs + ) + + if "1111" not in p_minus_p_minus_counts: + p_minus_p_minus_eval = 0 + else: + p_minus_p_minus_eval = p_minus_p_minus_counts["1111"] / sum( + p_minus_p_minus_counts.values() + ) + + if "1100" not in i_p_minus_counts: + i_p_minus_eval = 0 + else: + i_p_minus_eval = i_p_minus_counts["1100"] / sum(i_p_minus_counts.values()) + + if "0011" not in p_minus_i_counts: + p_minus_i_eval = 0 + else: + p_minus_i_eval = p_minus_i_counts["0011"] / sum(p_minus_i_counts.values()) + + v1 = 8 * p_minus_p_minus_eval - 4 * i_p_minus_eval + v2 = 8 * p_minus_p_minus_eval - 4 * p_minus_i_eval + lower_bound = max(v1, v2) + # k1 = 4 * p_minus_i_eval + # k2 = 4 * i_p_minus_eval + # upper_bound = min(k1, k2) + + # Add back the classical gates + co.add_classical_operations(circuit, classical_gates) + return lower_bound + + +# Tomography based entanglement measures + + +def eof(rho): + """ + Mixed state entanglement of formation as defined in PhysRevLett.80.2245 + :param rho: 2-qubit density matrix (pure or mixed) + :return: + """ + + def h(x): + return (-x * np.log2(x)) - ((1 - x) * np.log2(1 - x)) + + c = concurrence(rho) + if c == 0: + return 0 + return h(0.5 * (1 + np.sqrt(1 - c**2))) + + +def concurrence(rho): + """ + Mixed state concurrence as defined in PhysRevLett.80.2245 + :param rho: 2-qubit density matrix (pure or mixed) + :return: + """ + sigma_y = np.array([[0, -1j], [1j, 0]]) + sigma_y_sigma_y = np.kron(sigma_y, sigma_y) + rho_tilda = sigma_y_sigma_y @ rho.conjugate() @ sigma_y_sigma_y + eigenvalues = eig(rho @ rho_tilda, left=False, right=False) + # Make sure eigenvalues are real + if np.allclose(np.imag(eigenvalues), 0): + eigenvalues = np.real(eigenvalues) + else: + logger.warning(f"When calculating concurrence,eigenvalues were not real") + return 0 + lambdas = np.sqrt(eigenvalues.clip(min=0)) + lambdas = sorted(lambdas, reverse=True) + return np.max([0, lambdas[0] - lambdas[1] - lambdas[2] - lambdas[3]]) + + +def negativity(rho): + transposed = partial_transpose(rho) + t_norm = trace_norm(transposed) + return (t_norm - 1) / 2 + + +def log_negativity(rho): + transposed = partial_transpose(rho) + t_norm = trace_norm(transposed) + return np.log2(t_norm) + + +# Helper functions + + +def antisymmetric_subspace_projector_measurement_circuit(): + qr = QuantumRegister(2, "projection_qr") + cr = ClassicalRegister(2, "projection_cr") + qc = QuantumCircuit(qr, cr) + qc.cx(0, 1) + qc.h(0) + qc.measure(0, 0) + qc.measure(1, 1) + return qc.copy() + + +def partial_trace(statevector, a, b): + """ + Partial trace over all subsystems except qubit a and qubit b + :param statevector: Statevector + :param a: qubit a + :param b: qubit b + :return: Density matrix + """ + num_qubits = int(np.log2(len(statevector))) + if num_qubits == 2: + return np.outer(statevector, statevector.conj()) + qubits_to_trace_over = list(range(num_qubits)) + qubits_to_trace_over.remove(a) + qubits_to_trace_over.remove(b) + + return qi.partial_trace(statevector, qubits_to_trace_over).data + + +def partial_transpose(density_matrix, wrt=1): + """ + Partial transpose of density matrix + :param density_matrix: Bipartite system density matrix + :param wrt: Which subsystem transpose is supposed to be carried over + :return: density matrix + """ + tp = copy.deepcopy(density_matrix) + for ja, ka, jb, kb in itertools.product(range(2), range(2), range(2), range(2)): + if wrt == 1: + tp[ka * 2 + jb][ja * 2 + kb] = density_matrix[ja * 2 + jb][ka * 2 + kb] + elif wrt == 2: + tp[ja * 2 + kb][ka * 2 + jb] = density_matrix[ja * 2 + jb][ka * 2 + kb] + return tp + + +def trace_norm(density_matrix): + """ + Evaluate trace norm of density matrix + :return: float + """ + return np.real( + np.trace( + linalg.sqrtm( + np.matmul(density_matrix, np.conjugate(density_matrix).transpose()) + ) + ) + ) diff --git a/utils/adapt-aqc/adaptaqc/utils/fixed_ansatz_circuits.py b/utils/adapt-aqc/adaptaqc/utils/fixed_ansatz_circuits.py new file mode 100644 index 0000000000000000000000000000000000000000..c80c6e3194cd9c52d58685a196ae60c9d8d661f0 --- /dev/null +++ b/utils/adapt-aqc/adaptaqc/utils/fixed_ansatz_circuits.py @@ -0,0 +1,126 @@ +# (C) Copyright IBM 2025. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +"""Contains variational circuit ansatz such as hardware efficient ansatz""" +from qiskit import QuantumCircuit, QuantumRegister + +import adaptaqc.utils.circuit_operations as co +import adaptaqc.utils.constants as vconstants + + +def hardware_efficient_circuit( + num_qubits, + ansatz_kind, + ansatz_depth, + entangling_gate="cx", + coupling_map=None, + gates_to_fix=None, + gates_to_remove=None, +): + """ + Create a hardware efficient ansatz circuit. Each depth has a layers of + rotation gates followed by entangling gates. + The indexes are relative to the order in which the rotation gates are + added. + In example circuit, index of gate is shown after the gate name. + Example circuit (num_qubits:3, ansatz_kind: 'rxry', ansatz_depth:2, + linear entangling): + ┌───────┐┌───────┐ ┌───────┐┌───────┐ + ┤ Rx(0) ├┤ Ry(1) ├──■──┤ Rx(6) ├┤ Ry(7) ├───────────■─────────── + ├───────┤├───────┤┌─┴─┐└───────┘├───────┤┌───────┐┌─┴─┐ + ┤ Rx(2) ├┤ Ry(3) ├┤ X ├────■────┤ Rx(8) ├┤ Ry(9) ├┤ X ├────■──── + ├───────┤├───────┤└───┘ ┌─┴─┐ ├───────┤├───────┤└───┘ ┌─┴─┐ + ┤ Rx(4) ├┤ Ry(5) ├───────┤ X ├──┤ Rx(10)├┤ Ry(11)├───────┤ X ├── + └───────┘└───────┘ └───┘ └───────┘└───────┘ └───┘ + :param num_qubits: The number of qubits in circuit + :param ansatz_kind: String name of rotation gates (e.g. 'ry', 'rxry', + 'rzryrz') + :param ansatz_depth: Number of layers of (rotation gates+entangling gates) + :param entangling_gate: Entangling gate to use ('cx' or 'cz') + :param coupling_map: Map of entangling gates of the form [(control, + target)]. Gates are added sequentially. + If None, linear coupling layout is used + :param gates_to_fix: The indexes and angles of the gates which are to be + fixed (FIXED_GATE_LABEL is added to gate) + Must be of form {index:angle} + :param gates_to_remove: Indexes of gates which are to be removed + :return: QuantumCircuit + """ + qr = QuantumRegister(num_qubits) + qc = QuantumCircuit(qr) + + if coupling_map is None: + coupling_map = vconstants.coupling_map_linear(num_qubits) + if gates_to_remove is None: + gates_to_remove = [] + if gates_to_fix is None: + gates_to_fix = {} + + index = 0 + for _ in range(ansatz_depth): + # Add rotation gates + for qubit in range(num_qubits): + for gate_name in [ + ansatz_kind[i : i + 2] for i in range(0, len(ansatz_kind), 2) + ]: + gate = co.create_1q_gate(gate_name, 0) + if index in gates_to_fix: + gate.label = vconstants.FIXED_GATE_LABEL + gate.params[0] = gates_to_fix[index] + if index not in gates_to_remove: + qc.append(gate, [qr[qubit]]) + index += 1 + + for control, target in coupling_map: + qc.append(co.create_2q_gate(entangling_gate), [qr[control], qr[target]]) + + return qc + + +def number_preserving_ansatz(num_qubits, ansatz_depth): + coupling_map = vconstants.coupling_map_ladder(num_qubits) + + qc = QuantumCircuit(num_qubits) + index = 0 + for layer in range(ansatz_depth): + for control, target in coupling_map: + rz_gate = co.create_independent_parameterised_gate( + "rz", f"theta_" f"{index}" + ) + minus_rz_gate = co.create_dependent_parameterised_gate( + "rz", f"-theta_" f"{index}" + ) + ry_gate = co.create_independent_parameterised_gate("ry", f"phi_{index}") + minus_ry_gate = co.create_dependent_parameterised_gate( + "ry", f"-phi_" f"{index}" + ) + + qc.cx(control, target) + co.add_gate(qc, minus_rz_gate.copy(), qubit_indexes=[control]) + co.add_gate(qc, minus_ry_gate.copy(), qubit_indexes=[control]) + qc.cx(target, control) + co.add_gate(qc, ry_gate.copy(), qubit_indexes=[control]) + co.add_gate(qc, rz_gate.copy(), qubit_indexes=[control]) + qc.cx(control, target) + index += 1 + return qc + + +def custom_ansatz(num_qubits, two_qubit_circuit, ansatz_depth, coupling_map=None): + if coupling_map is None: + coupling_map = vconstants.coupling_map_ladder(num_qubits) + + qc = QuantumCircuit(num_qubits) + for layer in range(ansatz_depth): + for control, target in coupling_map: + co.add_to_circuit( + qc, two_qubit_circuit.copy(), qubit_subset=[control, target] + ) + return qc diff --git a/utils/adapt-aqc/adaptaqc/utils/gate_tomography.py b/utils/adapt-aqc/adaptaqc/utils/gate_tomography.py new file mode 100644 index 0000000000000000000000000000000000000000..9f4b7cede0b025731bd4cf812db02025cecaa5a6 --- /dev/null +++ b/utils/adapt-aqc/adaptaqc/utils/gate_tomography.py @@ -0,0 +1,104 @@ +# (C) Copyright IBM 2025. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +"""Contains methods for performing n-gate tomography""" +import numpy as np + + +def angle_sets_to_evaluate(num_params): + """Return the angles at which the expectation values + are to be measured for num_param gate tomography + + Args: + num_params (int): Number of rotation gates the + tomography is with respect to + + Returns: + np.ndarray([3**num_params,num_params]): Ordered + array of parameters values + """ + angles = np.zeros([3**num_params, num_params]) + for i in range(3**num_params): + base_3_str = np.base_repr(i, 3).zfill(num_params) + for j, ind in zip(range(num_params), base_3_str): + if ind == "0": + angles[i, j] = -np.pi / 2 + elif ind == "1": + angles[i, j] = 0 + elif ind == "2": + angles[i, j] = np.pi / 2 + return angles + + +def measurements_to_zero_delta_pi_bases(measurements): + """Tomography requires expactation values when each + angle is either 0, np.pi/2,-np.pi/2, and np.pi. + This method calculates the value for np.pi from + the other 3 values and arranges the data in + appropriate form + + Args: + measurements (np.ndarray): The expectation values + with angles obtained from angle_sets_to_evaluate + + Returns: + np.ndarray: New expectation value measurements + """ + num_params = int(np.log(len(measurements)) / np.log(3)) + new_measurements = np.array(measurements) + for j in range(num_params): + for i in range(3 ** (num_params - 1)): + if num_params == 1: + base_3_str = "" + else: + base_3_str = np.base_repr(i, 3).zfill(num_params - 1) + l_str = base_3_str[: num_params - (j + 1)] + r_str = base_3_str[num_params - (j + 1) :] + ind_0 = int(l_str + "0" + r_str, 3) + ind_1 = int(l_str + "1" + r_str, 3) + ind_2 = int(l_str + "2" + r_str, 3) + + val_minus_pi_by_2 = new_measurements[ind_0] + val_0 = new_measurements[ind_1] + val_pi_by_2 = new_measurements[ind_2] + + new_measurements[ind_0] = val_0 + new_measurements[ind_1] = val_pi_by_2 - val_minus_pi_by_2 + new_measurements[ind_2] = (val_pi_by_2 + val_minus_pi_by_2) - val_0 + + return new_measurements + + +def reconstructed_cost(angles, measurements): + """Calculate the cost from the tomography-reconstructed cost function + + Args: + angles (np.ndarray): Angles to evaluate expectation value at + measurements (np.ndarray): Expectation values of tomography measurements + + Returns: + float: Expectation value + """ + total = 0 + num_params = len(angles) + for i in range(3**num_params): + product = 1 + product *= measurements[i] + base_3_str = np.base_repr(i, 3).zfill(num_params) + for j in range(num_params): + angle = angles[j] / 2 + if base_3_str[j] == "0": + product *= np.cos(angle) * np.cos(angle) + elif base_3_str[j] == "1": + product *= np.cos(angle) * np.sin(angle) + elif base_3_str[j] == "2": + product *= np.sin(angle) * np.sin(angle) + total += product + return total diff --git a/utils/adapt-aqc/adaptaqc/utils/gradients.py b/utils/adapt-aqc/adaptaqc/utils/gradients.py new file mode 100644 index 0000000000000000000000000000000000000000..5073e5a8940c1ae8b633cb4e22b674fb489e2a30 --- /dev/null +++ b/utils/adapt-aqc/adaptaqc/utils/gradients.py @@ -0,0 +1,224 @@ +# (C) Copyright IBM 2025. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +from typing import List, Tuple + +import numpy as np +from aqc_research.mps_operations import mps_from_circuit, mps_dot +from qiskit import QuantumCircuit + +from adaptaqc.backends.aer_mps_backend import AerMPSBackend +from adaptaqc.backends.python_default_backends import MPS_SIM +from adaptaqc.utils.circuit_operations import remove_unnecessary_2q_gates_from_circuit +from adaptaqc.utils.utilityfunctions import get_distinct_items_and_degeneracies + + +def general_grad_of_pairs( + circuit: QuantumCircuit, + inverse_zero_ansatz: QuantumCircuit, + generators: List[QuantumCircuit], + degeneracies: List[int], + coupling_map: List[Tuple], + starting_circuit=None, + backend: AerMPSBackend = MPS_SIM, +): + """ + For an ansatz of the form U(θ) = U_N(θ_N) * ... * U_1(θ_1), parameterised by θ = (θ_1, ..., θ_N), + and with U_k(θ_k) = exp(-i * (θ_k / 2) * A_k), this function: + 1. Calculates the cost-gradient with respect to each θ_k at θ=0. The gradient is given by: + dC/d(θ_k)|θ=0 = -imag(<ψ|U†(0)|s>) = g_k + where: + • |s> is the state obtained by acting with the starting_circuit on |0> + • U†(0) is the inverse of the ansatz evaluated at θ=0 + • G_k = U_N(0) * ... * U_(k+1)(0) * A_k * U_(k-1)(0) * ... * U_1(0) I.e. the ansatz + evaluated at θ=0 BUT with U_k replaced by its generator A_k + 2. Calculates the Euclidean norm of the gradients: g = sqrt(g_1 ** 2 + ... + g_N ** 2) + 3. Returns a list of the gradient g for each pair in the coupling map + + Args: + circuit (QuantumCircuit): a circuit representing |ψ> + inverse_zero_ansatz (QuantumCircuit): a circuit representing U†(0) + generators (List[QuantumCircuit]): a list of quantum circuits representing (G_k)† + degeneracies (List[int]): a list of the degeneracies of the generators + coupling_map (List[Tuple]): the list of all pairs of qubits for which to calculate the gradient + starting_circuit (QuantumCircuit): a circuit representing |s> + backend (AerSimulator): Aer MPS simulator used to generate relevant states + Returns: + gradients (List): List of gradients g for each pair + """ + gradients = [] + ansatz_resolves_to_id = inverse_zero_ansatz == QuantumCircuit(2) + + # Get MPS of |ψ> + circ_mps = mps_from_circuit( + circuit.copy(), return_preprocessed=True, sim=backend.simulator + ) + + # Get the starting circuit + if starting_circuit is not None: + starting_circuit = starting_circuit + else: + starting_circuit = QuantumCircuit(circuit.num_qubits) + + # Only calculate <ψ|U†(0)|s> = <ψ|s> once if ansatz resolves to identity + if ansatz_resolves_to_id: + # Find |s> + starting_circuit_mps = mps_from_circuit( + starting_circuit.copy(), return_preprocessed=True, sim=backend.simulator + ) + # Find <ψ|s> + zero_ansatz_overlap = mps_dot( + circ_mps, starting_circuit_mps, already_preprocessed=True + ) + + for control, target in coupling_map: + # Calculate <ψ|U†(0)|s> for each pair if ansatz does not resolve to identity + if not ansatz_resolves_to_id: + # Find U†(0)|s> + ansatz_on_starting_circuit = starting_circuit.compose( + inverse_zero_ansatz, [control, target] + ) + ansatz_on_starting_circuit_mps = mps_from_circuit( + ansatz_on_starting_circuit, + return_preprocessed=True, + sim=backend.simulator, + ) + # Find <ψ|U†(0)|s> + zero_ansatz_overlap = mps_dot( + circ_mps, ansatz_on_starting_circuit_mps, already_preprocessed=True + ) + + gradient = 0 + for i, generator in enumerate(generators): + # Find (G_k)†|s> + generator_on_starting_circuit = starting_circuit.compose( + generator, [control, target] + ) + generator_on_starting_circuit_mps = mps_from_circuit( + generator_on_starting_circuit, + return_preprocessed=True, + sim=backend.simulator, + ) + # Find , computed as the dot product of (G_k)†|s> and |ψ> + generator_overlap = mps_dot( + generator_on_starting_circuit_mps, circ_mps, already_preprocessed=True + ) + + generator_gradient = -1 * np.imag(generator_overlap * zero_ansatz_overlap) + + # Add contribution to gradient from generator, accounting for degeneracy + gradient += (generator_gradient**2) * degeneracies[i] + + # Calculate the Euclidean norm of the gradients for each generator + grad_norm = np.sqrt(gradient) + + gradients.append(grad_norm) + + return gradients + + +def get_generators_and_degeneracies( + ansatz: QuantumCircuit, rotoselect: bool = False, inverse: bool = False +): + """ + For an ansatz of the form U(θ) = U_N(θ_N) * ... * U_1(θ_1), parameterised by θ = (θ_1, ..., θ_N), + and with U_k(θ_k) = exp(-i * (θ_k / 2) * A_k), this function finds the generators of the ansatz: + + G_k = U_N(0) * ... * U_(k+1)(0) * A_k * U_(k-1)(0) * ... * U_1(0) I.e. the ansatz evaluated at + θ=0 BUT with U_k replaced by its generator A_k. + + If rotoselect=True, for every rotation gate in the ansatz, return all three generators as if the + rotation gate was Rx, Ry, or Rz. + + Args: + ansatz (QuantumCircuit): a circuit representing the ansatz U + rotoselect (bool): set to True to return the x, y, z generators for each rotation gate, set + to False to only return the specific generator for the gate. + inverse (bool): set to True to return the inverse of the generators + Returns: + generator_circuits (List[QuantumCircuit]): List of generators G_k (or their inverses), one + for each parameterised gate if rotoselect=False, three if rotoselect=True. + degeneracies (List[int]): List of degeneracies of generators. + """ + parameterised_gates = ["rx", "ry", "rz"] + generator_circuits = [] + for i, circ_instr in enumerate(ansatz): + if circ_instr.operation.name in parameterised_gates: + if rotoselect: + # Get all Rx, Ry, Rz generators + for op in parameterised_gates: + generator = get_generator(ansatz, i, op) + generator_circuits.append( + generator.inverse() if inverse else generator + ) + else: + # Get the generator for the specific gate + generator = get_generator(ansatz, i, circ_instr.operation.name) + generator_circuits.append(generator.inverse() if inverse else generator) + + distinct_generators, degeneracies = get_distinct_items_and_degeneracies( + generator_circuits + ) + + return (distinct_generators, degeneracies) + + +def get_generator(ansatz: QuantumCircuit, index: int, op: str): + """ + Given an ansatz consisting of only rx, ry, rz and cx gates, this function replaces the gate at + index=index with the generator of op, removes all other rotation gates, and removes consecutive + cx gates that would resolve to the identity. + + Example: + index = 4, op = 'ry', ansatz: + ┌───────┐ ┌───────┐ ┌───────┐ + q_0: ┤ Rx(0) ├──■──┤ Rx(0) ├──■──┤ Rx(0) ├ + ├───────┤┌─┴─┐├───────┤┌─┴─┐├───────┤ + q_1: ┤ Rx(0) ├┤ X ├┤ Rx(0) ├┤ X ├┤ Rx(0) ├ + └───────┘└───┘└───────┘└───┘└───────┘ + + will return: + q_0: ──■─────────■── + ┌─┴─┐┌───┐┌─┴─┐ + q_1: ┤ X ├┤ Y ├┤ X ├ + └───┘└───┘└───┘ + + Args: + ansatz (QuantumCircuit): a circuit representing the ansatz + index: the index of the operator to be replaced + op: the operator, one of rx, ry or rz, the generator of which will replace the gate at + index=index + Returns: + generator (QuantumCircuit): The generator + """ + supported_ops = ["rx", "ry", "rz"] + if op not in supported_ops: + raise ValueError("op must be one of rx, ry or rz") + + generator = QuantumCircuit(2) + for i, circ_instr in enumerate(ansatz): + operation = circ_instr.operation + qubits = circ_instr.qubits + if operation.name not in ["rx", "ry", "rz", "cx"]: + raise ValueError("Circuit must only contain rx, ry, rz and cx gates") + if i == index: + if op == "rx": + generator.x(qubits[0]) + if op == "ry": + generator.y(qubits[0]) + if op == "rz": + generator.z(qubits[0]) + if operation.name == "cx": + generator.cx(qubits[0], qubits[1]) + + # remove consecutive cx gates which resolve to the identity + remove_unnecessary_2q_gates_from_circuit(generator) + + return generator diff --git a/utils/adapt-aqc/adaptaqc/utils/hamiltonians.py b/utils/adapt-aqc/adaptaqc/utils/hamiltonians.py new file mode 100644 index 0000000000000000000000000000000000000000..c798e145e34eafe963d485b8fca5e990e812f055 --- /dev/null +++ b/utils/adapt-aqc/adaptaqc/utils/hamiltonians.py @@ -0,0 +1,85 @@ +# (C) Copyright IBM 2025. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +import numpy as np +from openfermion import ( + FermionOperator, + QubitOperator, + get_ground_state, + get_sparse_operator, + jordan_wigner, +) + + +def heisenberg_hamiltonian( + n=4, jx=1.0, jy=0.0, jz=0.0, hx=0.0, hy=0.0, hz=0.0, periodic_bc=False +): + """ + H = -sum_over_nn(j_x*X_i*X_(i+1) + j_y*Y_i*Y_(i+1) + j_z*Z_i*Z_(i+1)) + -sum(h_x*X_i + h_y*Y_i + h_z*Z_i) + """ + ham = QubitOperator() + max_index = n if periodic_bc else n - 1 + for i in range(max_index): + next_neighbour_index = 0 if i == n - 1 and periodic_bc else i + 1 + ham += QubitOperator(f"X{i} X{next_neighbour_index}", -jx) + ham += QubitOperator(f"Y{i} Y{next_neighbour_index}", -jy) + ham += QubitOperator(f"Z{i} Z{next_neighbour_index}", -jz) + for i in range(n): + ham += QubitOperator(f"X{i}", -hx) + ham += QubitOperator(f"Y{i}", -hy) + ham += QubitOperator(f"Z{i}", -hz) + return ham + + +def anderson_model_fermionic_hamiltonian( + v_i=np.array([0, 1]), epsilon_i=np.array([2, 2]), u=4, mu=0 +): + if len(v_i) != len(epsilon_i): + raise ValueError( + f"Number of elements in v_i ({len(v_i)}) must equal number of " + f"elements in epsilon_i({len(epsilon_i)})" + ) + num_bath_sites = len(v_i) - 1 + ham = FermionOperator() + + # Coulomb repulsion + ham += FermionOperator(f"0^ 0 {num_bath_sites + 1}^ {num_bath_sites + 1}", float(u)) + + # Bath site energies + for site_index in range(0, 1 + num_bath_sites): + for spin in range(2): + i = site_index + (spin * (1 + num_bath_sites)) + ham += FermionOperator(f"{i}^ {i}", float(epsilon_i[site_index] - mu)) + # Hybridization energies + for site_index in range(1, 1 + num_bath_sites): + for spin in range(2): + i = site_index + (spin * (1 + num_bath_sites)) + impurity_index = spin * (1 + num_bath_sites) + ham += FermionOperator(f"{impurity_index}^ {i}", float(v_i[site_index])) + ham += FermionOperator(f"{i}^ {impurity_index}", float(v_i[site_index])) + + return ham + + +def anderson_model_qubit_hamiltonian( + v_i=np.array([0, 1]), epsilon_i=np.array([2, 2]), u=4, mu=0 +): + f_ham = anderson_model_fermionic_hamiltonian(v_i, epsilon_i, u, mu) + qubit_ham = jordan_wigner(f_ham) + return qubit_ham + + +def calculate_ground_state(hamiltonian): + gs_energy, gs_wf = get_ground_state(get_sparse_operator(hamiltonian)) + # eigvals, eigvecs = eigh(get_sparse_operator(hamiltonian).toarray()) + # gs_energy = eigvals[0] + # gs_wf = eigvecs[:,0] + return gs_energy, gs_wf diff --git a/utils/adapt-aqc/adaptaqc/utils/utilityfunctions.py b/utils/adapt-aqc/adaptaqc/utils/utilityfunctions.py new file mode 100644 index 0000000000000000000000000000000000000000..18662097b5000a6a4427c2d0b3691ed269683095 --- /dev/null +++ b/utils/adapt-aqc/adaptaqc/utils/utilityfunctions.py @@ -0,0 +1,481 @@ +# (C) Copyright IBM 2025. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +"""Contains functions""" + +import copy +import functools +from collections.abc import Iterable +from typing import Union, Dict, List, Tuple + +import aqc_research.mps_operations as mpsop +import numpy as np +from qiskit import QuantumCircuit +from qiskit import transpile +from qiskit.result import Counts +from qiskit_aer.backends.compatibility import Statevector +from tenpy import SpinHalfSite, SpinSite +from tenpy.networks.mps import MPS + +from adaptaqc.backends.aer_sv_backend import AerSVBackend +from adaptaqc.utils.circuit_operations import SUPPORTED_1Q_GATES + + +# ------------------Trigonometric functions------------------ # + + +def minimum_of_sinusoidal(value_0, value_pi_by_2, value_minus_pi_by_2): + """ + Find the minimum of a sinusoidal function with period 2*pi and of the + form f(x) = a*sin(x+b)+c + :param value_0: f(0) + :param value_pi_by_2: f(pi/2) + :param value_minus_pi_by_2: f(-pi/2) + :return: (x_min, f(x_min)) + """ + theta_min = -(np.pi / 2) - np.arctan2( + 2 * value_0 - value_pi_by_2 - value_minus_pi_by_2, + value_pi_by_2 - value_minus_pi_by_2, + ) + + theta_min = normalized_angles(theta_min) + + intercept_c = 0.5 * (value_pi_by_2 + value_minus_pi_by_2) + value_pi = (value_pi_by_2 + value_minus_pi_by_2) - value_0 + amplitude_a = 0.5 * ( + ((value_0 - value_pi) ** 2 + (value_pi_by_2 - value_minus_pi_by_2) ** 2) ** 0.5 + ) + value_theta_min = intercept_c - amplitude_a + + return theta_min, value_theta_min + + +def amplitude_of_sinusoidal(value_0, value_pi_by_2, value_minus_pi_by_2): + """ + Find the amplitude of a sinusoidal function with period 2*pi and of the + form f(x) = a*sin(x+b)+c + :param value_0: f(0) + :param value_pi_by_2: f(pi/2) + :param value_minus_pi_by_2: f(-pi/2) + :return: Amplitude + """ + + value_pi = (value_pi_by_2 + value_minus_pi_by_2) - value_0 + amplitude_a = 0.5 * ( + ((value_0 - value_pi) ** 2 + (value_pi_by_2 - value_minus_pi_by_2) ** 2) ** 0.5 + ) + + return amplitude_a + + +def derivative_of_sinusoidal(theta, value_0, value_pi_by_2, value_minus_pi_by_2): + """ + Find the derivative of a sinusoidal function with period 2*pi and of the + form f(x) = a*sin(x+b)+c at x=theta + :param theta: Angle at which derivative is to be evaluated + :param value_0: f(0) + :param value_pi_by_2: f(pi/2) + :param value_minus_pi_by_2: f(-pi/2) + :return: df(x)/dx at x=theta + """ + value_pi = (value_pi_by_2 + value_minus_pi_by_2) - value_0 + amplitude_a = 0.5 * ( + ((value_0 - value_pi) ** 2 + (value_pi_by_2 - value_minus_pi_by_2) ** 2) ** 0.5 + ) + phase_b = np.arctan2(value_0 - value_pi, value_pi_by_2 - value_minus_pi_by_2) + + derivative = amplitude_a * np.cos(theta + phase_b) + return derivative + + +def normalized_angles(angles): + """ + Normalize angle(s) to between -pi, pi by adding/subtracting multiples of + 2pi + :param angles: float or Iterable(float) + :return: float or Iterable(float) + """ + single = not isinstance(angles, Iterable) + if single: + angles = [angles] + new_angles = [] + for angle in angles: + while (angle > np.pi) or (angle < -np.pi): + if angle > np.pi: + angle -= 2 * np.pi + elif angle < np.pi: + angle += 2 * np.pi + new_angles += [angle] + return new_angles[0] if single else new_angles + + +# ------------------Misc. functions------------------ # + + +def is_statevector_backend(backend): + """ + Check if backend is a statevector simulator backed + :param backend: Simulator backend + :return: Boolean + """ + if isinstance(backend, AerSVBackend): + return True + return False + + +def counts_data_from_statevector( + statevector, + num_shots=2**40, +): + """ + Get counts data from statevector by multiplying amplitude squares with num_shots. + Note: Doesn't guarantee total number of shots in returned counts data will be num_shots. + Warning: Doesn't work well if num_shots << number of non-zero elements in statevector + :param statevector: Statevector (list/array) + :return: Counts data (e.g. {'00':13, '10':7}) with bitstrings ordered + with decreasing qubit number + """ + num_qubits = int(np.log2(len(statevector))) + counts = {} + probs = np.absolute(statevector) ** 2 + bit_str_array = [bin(i)[2:].zfill(num_qubits) for i in range(2**num_qubits)] + counts = dict(zip(bit_str_array, np.asarray(probs * num_shots, int))) + # counts = dict(zip(*np.unique(np.random.choice(bit_str_array, num_shots,p=probs),return_counts=True))) + return counts + + +def statevector_from_counts_data(counts): + """ + Get statevector from counts (works only for real, positive states) + :param: Counts data (e.g. {'00':13, '10':7}) + :return statevector: Statevector (list/array) + """ + num_qubits = len(list(counts.keys())[0]) + sv = np.zeros(2**num_qubits) + for i in range(2**num_qubits): + bitstr = bin(i)[2:].zfill(num_qubits) + if bitstr in counts: + sv[i] = counts[bitstr] ** 0.5 + sv /= np.linalg.norm(sv) + return sv + + +def expectation_value_of_qubits(data: Union[Counts, Dict, Statevector]): + """ + Expectation value of qubits (in computational basis) + :param counts: Counts data (e.g. {'00':13, '10':7}) + :return: [expectation_value(float)] + """ + data = Statevector(data) if isinstance(data, np.ndarray) else data + + num_qubits = ( + data.num_qubits if isinstance(data, Statevector) else len(list(data)[0]) + ) + + expectation_values = [] + for i in range(num_qubits): + expectation_values.append(_expectation_value_of_qubit(i, data, num_qubits)) + return expectation_values + + +def expectation_value_of_qubits_mps(circuit: QuantumCircuit, sim=None): + """ + Expectation value of qubits (in computational basis) using mps + :param circuit: Circuit corresponding to state + :param sim: MPS AerSimulator instance. If none, will use default in AQC Research. + :return: [expectation_value(float)] + """ + # Get mps from circuit + circ = circuit.copy() + mps = mpsop.mps_from_circuit(circ, return_preprocessed=True, sim=sim) + + num_qubits = circuit.num_qubits + + expectation_values = [ + (mpsop.mps_expectation(mps, "Z", i, already_preprocessed=True)) + for i in range(num_qubits) + ] + return expectation_values + + +def _expectation_value_of_qubit( + qubit_index, data: Union[Counts, Statevector], num_qubits +): + """ + Expectation value of qubit (in computational basis) at given index + :param qubit_index: Index of qubit (int) + :param data: Counts data (e.g. {'00':13, '10':7}) or Statevector + :return: [expectation_value(float)] + """ + if qubit_index >= num_qubits: + raise ValueError("qubit_index outside of register range") + + reverse_index = num_qubits - (qubit_index + 1) + + if type(data) is Statevector: + [p0, p1] = data.probabilities([qubit_index]) + exp_val = p0 - p1 + return exp_val + + else: + exp_val = 0 + total_counts = 0 + for bitstring in list(data): + exp_val += (1 if bitstring[reverse_index] == "0" else -1) * data[bitstring] + total_counts += data[bitstring] + return exp_val / total_counts + + +def expectation_value_of_pauli_observable(counts, pauli): + """ + Copied from measure_pauli_z in qiskit.aqua.operators.common + + Args: + counts (dict): a dictionary of the form counts = {'00000': 10} ({ + str: int}) + pauli (Pauli): a Pauli object + Returns: + float: Expected value of paulis given data + """ + observable = 0.0 + num_shots = sum(counts.values()) + p_z_or_x = np.logical_or(pauli.z, pauli.x) + for key, value in counts.items(): + bitstr = np.asarray(list(key))[::-1].astype(bool) + sign = ( + -1.0 + if functools.reduce(np.logical_xor, np.logical_and(bitstr, p_z_or_x)) + else 1.0 + ) + observable += sign * value + observable /= num_shots + return observable + + +def remove_permutations_from_coupling_map(coupling_map): + seen = set() + unique_list = [] + for pair in coupling_map: + if tuple(sorted(pair)) not in seen: + seen.add(tuple(sorted(pair))) + unique_list.append(pair) + return unique_list + + +def has_stopped_improving(cost_history, rel_tol=1e-2): + try: + poly_fit_res = np.polyfit(list(range(len(cost_history))), cost_history, 1) + grad = poly_fit_res[0] / np.absolute(np.mean(cost_history)) + return grad > -1 * rel_tol + except np.linalg.LinAlgError: + return False + + +def multi_qubit_gate_depth(qc: QuantumCircuit) -> int: + """ + Return the multi-qubit gate depth. + + When the circuit has been transpiled for IBM Quantum hardware + this will be equivalent to the CNOT depth. + """ + return qc.depth(filter_function=lambda instr: len(instr.qubits) > 1) + + +def tenpy_to_qiskit_mps(tenpy_mps): + num_sites = tenpy_mps.L + tenpy_mps.canonical_form() + + # Check convention of basis states + flip = check_flipped_basis_states(tenpy_mps) + + gam = [0] * num_sites + lam = [0] * (num_sites - 1) + permutation = None + for n in range(num_sites): + # Get the tenpy "B" tensor for site n, with indices in Qiskit MPS order (p, L, R) + g_n = tenpy_mps.get_B(n, form="G").itranspose(["p", "vL", "vR"]).to_ndarray() + if permutation is not None: + g_n[:] = g_n[ + :, permutation, : + ] # permute left index in the same way the left singlular values were permuted + if n < num_sites - 1: + l_n = tenpy_mps.get_SR(n) # Get singular values to the right of tensor n + permutation = np.argsort(l_n)[::-1] + l_n = np.sort(l_n)[::-1] # Sort singular values in descending order + lam[n] = l_n + if permutation is not None: + g_n[:] = g_n[ + :, :, permutation + ] # permute right index in the same way the right singular values were permuted + + # Split physical dimension into two parts of a tuple + if flip[n]: + gam[n] = (g_n[1], g_n[0]) + else: + gam[n] = (g_n[0], g_n[1]) + + qiskit_mps = (gam, lam) + + return copy.deepcopy(qiskit_mps) + + +def tenpy_chi_1_mps_to_circuit(mps: MPS) -> QuantumCircuit: + if not np.allclose(mps.chi, 1): + raise Exception("MPS must have bond dimension 1 for all bonds.") + + flip = check_flipped_basis_states(mps) + + qc = QuantumCircuit(mps.L) + for i in range(mps.L): + # 2 x 1 x 1 array representing the state of site i + array = mps.get_B(i, form="B").itranspose(["p", "vL", "vR"]).to_ndarray() + # Extract the length-2 vector, with the correct basis-ordering + if flip[i]: + vec = array[::-1, 0, 0] + else: + vec = array[:, 0, 0] + + # Make unitary with column 0 corresponding to the state of site i + U = np.zeros((2, 2), dtype=array.dtype) + U[:, 0] = vec + U[0, 1] = np.conj(U[1, 0]) + U[1, 1] = -np.conj(U[0, 0]) + qc.unitary(U, i) + + qc = transpile(qc, basis_gates=["rx", "ry", "rz"]) + return qc + + +def qiskit_to_tenpy_mps(qiskit_mps, return_form: str = "SpinSite") -> MPS: + """ + Converts a Qiskit MPS to a Tenpy MPS. + + Args: + qiskit_mps: The Qiskit MPS. + return_form: The type of site to use for the Tenpy MPS. + Returns: + tenpy_mps: The Tenpy MPS + """ + # If not preprocessed, preprocess MPS + if isinstance(qiskit_mps[0], List): + qiskit_mps = mpsop._preprocess_mps(qiskit_mps) + + N = len(qiskit_mps) + + if return_form == "SpinSite": + sites = [SpinSite(conserve=None)] * N + # Flip basis state ordering for SpinSite + qiskit_mps = [tensor[::-1, :, :] for tensor in qiskit_mps] + elif return_form == "SpinHalfSite": + sites = [SpinHalfSite(conserve=None)] * N + else: + raise ValueError( + f"Invalid return_form: {return_form}. Must be SpinSite or SpinHalfSite" + ) + + tenpy_mps = MPS.from_Bflat(sites, qiskit_mps, SVs=None) + + return tenpy_mps + + +def find_rotation_indices(qc: QuantumCircuit, indices: List[int]) -> List[int]: + """ + Given a QuantumCircuit and a list of indices, returns a list containing the subset of indices + corresponding to rotation gates in the circuit + """ + rotation_indices = [] + for index in indices: + if qc.data[index].operation.name in SUPPORTED_1Q_GATES: + rotation_indices.append(index) + + return rotation_indices + + +def get_distinct_items_and_degeneracies(items: List) -> Tuple[List, List[int]]: + """ + Given a list of items, return a list containing the distinct items, along with their + degeneracies (number of repetitions). + + Args: + items: List of items. + Returns: + distinct_items: List of distinct items. + degeneracies: List of degeneracies. + """ + distinct_items = [] + degeneracies = [] + for i in range(len(items)): + item = items[i] + distinct = True + for j in range(len(distinct_items)): + if item == distinct_items[j]: + degeneracies[j] += 1 + distinct = False + break + if distinct: + distinct_items.append(item) + degeneracies.append(1) + + return (distinct_items, degeneracies) + + +def check_flipped_basis_states(mps: MPS) -> List[bool]: + """ + Given a Tenpy MPS, generate a list where the ith element is False(True) if the ith site of the + MPS is(isn't) ordering the basis states with the same convention as Qiskit. + + Args: + mps: The Tenpy MPS. + Returns: + flipped_basis_states: The list of basis conventions. + """ + + flipped_basis_states = [None] * mps.L + + for i in range(mps.L): + sz_matrix = mps.sites[i].get_op("Sz").to_ndarray() + if np.array_equal(sz_matrix, [[0.5, 0], [0, -0.5]]): + flipped_basis_states[i] = False + elif np.array_equal(sz_matrix, [[-0.5, 0], [0, 0.5]]): + flipped_basis_states[i] = True + else: + raise ValueError(f"Invalid Tenpy convention for site {i}") + + return flipped_basis_states + + +def tenpy_mps_to_statevector(mps: MPS) -> np.ndarray: + """ + Convert a Tenpy MPS to a little-endian statevector + + Args: + mps: The MPS. + Returns: + sv: The statevector. + """ + + # Get the 2 x 2 x ... tensor representing the state + sv = mps.get_theta(0, mps.L).to_ndarray().reshape([2] * mps.L) + + # Flip the basis ordering for any sites using the opposite convention to Qiskit + flip = check_flipped_basis_states(mps) + for i in range(mps.L): + if flip[i]: + sv = np.flip(sv, axis=i) + else: + continue + + # Convert from big-endian to little-endian ordering + sv = np.transpose(sv, axes=range(mps.L)[::-1]) + + # Convert to 2^N dimensional vector + sv = sv.flatten() + + return sv diff --git a/utils/adapt-aqc/docs/future_heuristic_ideas.md b/utils/adapt-aqc/docs/future_heuristic_ideas.md new file mode 100644 index 0000000000000000000000000000000000000000..057cdb0354cd9765e7ee7a375bcbe2cdec1f2d78 --- /dev/null +++ b/utils/adapt-aqc/docs/future_heuristic_ideas.md @@ -0,0 +1,66 @@ +This document is to detail some ideas for potential future features. + +# Local minima + +## random_cost_noise + +**What is it:** \ +Every time the cost function is evaluated, some noise value $\epsilon$ is added. The distribution +that $\epsilon$ is drawn from could be uniform, Gaussian or another and perhaps user defined. Likely +the magnitude of $\epsilon$ would need to be adjusted depending on the number of qubits. + +**What problem it aims to solve:** \ +Adding noise is thought to help avoid getting stuck in local minima. This is a technique applied +in DMRG (see https://itensor.org/support/2529/details-of-how-dmrg-works), and also explored e.g., in +deep learning (https://arxiv.org/abs/1511.06807). + +## add_local_minima_to_cost + +**What is it:** \ +If we have identified that ADAPT-AQC is stuck in a local minima, we could save the MPS $|LM\rangle$ +at +this point. Then, we could explicitly add to the cost function a new term that would be the overlap +of the trial solution to this MPS. So the cost would then be + +$$C = 1 - |\langle 0 | V^\dagger U|0\rangle|^2 + |\langle LM| V^\dagger U|0\rangle|^2$$$ + +Since the cost is being minimised, this encourages ADAPT-AQC to minimise the overlap between the +current +solution $V^\dagger U|0\rangle$ and the state that was in a local minima $|LM\rangle$. + +**What problem it aims to solve:** \ +This is another technique borrowed from DMRG, which we learnt about in a seminar (reference needed +for what this is called in DMRG literature). The idea is once we have identified a local minima, we +can repel the optimisation away from this area of the cost landscape. + +# Performance + +## optimiser == "gradient_descent" + +**What is it:** \ +Gradient descent is the most popular optimisation algorithm in classical and quantum ML alike. At +the moment, ADAPT-AQC does not use gradient descent and instead uses non-gradient based sequential +optimisation in the form of the Rotosolve/Rotoselect algorithms. + +**What problem it aims to solve:** \ +Gradient descent was originally not used due to fears over encountering barren plateaus. However, +when running ADAPT-AQC fully classically, barren plateaus should not be an issue as we can access +observables with exponential precision. The benefit of gradient descent would be a potentially +large performance improvement, as the gradients of each parameter can be calculated independently +of one another. Note, however, that this improvement would be mostly dependent on calculating +gradients using backpropagation (for simulated circuits), as opposed to parameter-shift. + +## parallel_rotosolve + +**What is it:** \ +Given $P$ parameterised gates, Rotosolve cycles through them one-by-one finding the optimal angle +in the case that all others are fixed. Since the optimal gate angles are dependent on one another, +this cycle is repeated until the cost function converges (i.e., does not change by a defined amount +between two cycles). However, if we assume independence of the parameters, we could optimise each +gate in parallel. This could be done with no downside if the gates truly are independent (e.g., not +in a light-cone of each other) or as an approximation with the hope that the optimal angles in +parallel are not too far from those in sequence. + +**What problem it aims to solve:** \ +With enough computational threads, parallelising Rotosolve could lead to a performance +improvement. \ No newline at end of file diff --git a/utils/adapt-aqc/docs/running_options_explained.md b/utils/adapt-aqc/docs/running_options_explained.md new file mode 100644 index 0000000000000000000000000000000000000000..5ab740131ebaf0ed5c796e3b47bd19830b3c0806 --- /dev/null +++ b/utils/adapt-aqc/docs/running_options_explained.md @@ -0,0 +1,296 @@ +This document is to give a further in-detail explanation about the different ADAPT-AQC options made +available through `AdaptConfig` and `AdaptCompiler`. + +# AdaptConfig() + +## method="general_gradient" + +This is the core heuristics of ADAPT-AQC, whereby the next two-qubit unitary is placed on the +pair of qubits which would give the largest cost gradient $\Vert\vec{\nabla} C\Vert$. +For more details please see Appendix A of https://arxiv.org/abs/2503.09683. + +## method="ISL" + +**What is it:** \ +When using this method, the ansatz is adaptively constructed +by prioritising pairs of qubits which have larger pairwise entanglement. This is the original +heuristic from https://github.com/abhishekagarwal2301/isl which has been kept as the default here +as it is the only one which supports all backends. + +When +`AdaptConfig.reuse_exponent = 0`, which is the default setting, the pair with the largest +entanglement is always picked, except for picking the same pair twice. For any other +value of `reuse_exponent`, the entanglement of the pair is weighted against how +recently a layer has been applied to it. + +If the pairwise entanglement between all qubits in the coupling map is zero, this method falls +back to the `expectation` method defined below. + +**What problem it aims to solve:** \ +The goal here is to use the entanglement structure of the compilation target to inform the adaptive +ansatz. This is motivated by the fact that the compiling succeeds by finding a set of gates that +"undoes" the target circuit back to the $|00..0\rangle$ state. Since the $|00..0\rangle$ state has +no pairwise entanglement, it makes sense that we want to iteratively reduce this. + +## method="expectation" + +**What is it:** \ +This is another mode of operation for compiling. When using this method, the ansatz is adaptively +constructed by prioritising pairs which have smallest summed $\hat{\sigma}_z$ expectation values ( +i.e., the closest to the minimum value of -2) + +For the default value of `AdaptConfig.expectation_reuse_exponent = 1` the pair with the smallest +expectation value is also weighted against how recently a layer has been applied to it. + +**What problem it aims to solve:** \ +In this case, we aim to use the qubit magnetisation of the target to inform the adaptive ansatz. +This has a similar motivation to above, in that each qubit in the $|00..0\rangle$ state has +an expectation value of $\langle0|\hat{\sigma}_z|0\rangle = 1$ + +## bad_qubit_pair_memory + +**What is it:** \ +For the ADAPT-AQC method, if acting on a qubit pair leads to entanglement increasing, it is labelled +a +"bad pair". After this, for a number of layers corresponding to the bad_qubit_pair_memory, +this pair will not be selected. + +**What problem it aims to solve:** \ +Although pairwise entanglement is used to select a two-qubit pair to act on, the variational +ansatz is optimised with respect to the overlap with the $|00..0\rangle$ state. The algorithm thus +does +not directly minimise entanglement. This means that in certain situations, the optimal angles of an +ansatz layer could actually increase the entanglement. This would lead to a high priority for acting +on that pair of qubits in the near-future, despite the fact that it was recently optimised. This +can lead ADAPT-AQC to get stuck, particularly if there are several unconnected bad qubit pairs. The +use +of `bad_qubit_pair_memory` is to make sure that by the time the pair is acted on again, +the state of connected qubits has sufficiently changed so that optimising the bad pairs will lead +to new optimal angles. + +## reuse_exponent + +**What is it:** \ +For the ADAPT-AQC, expectation or general_gradient methods, this controls how much priority should +be given to picking qubits not recently +acted on. Specifically, given a qubit pair has been last acted on $l$ layers ago, it is given a +reuse priority $P_r$ of + +$$P_r = 1-2^{\frac{-l}{k}},$$ + +where $k$ is the value of `reuse_exponent`. This is then multiplied with the +entanglement measure or gradient (for ADAPT-AQC and general_gradient respectively) to produce the +combined priority $P_c$ = $E*P_r$. For expectation mode, the combined priority is calculated +differently. Given a pair of qubits, the combined priority is calculated as + +$$P_c = (2 - \langle Z_1 \rangle + \langle Z_2 \rangle) *P_r$$, + +where $\langle Z_1 \rangle$ ($\langle Z_2 \rangle$) is the $\\hat{\sigma}_z$ expectation value of +qubit +1 (2). + +The qubit pair with the highest combined priority is then picked for the next layer. + +This means that for larger $k$, more weighting is given to how recently the pair was used. +Conversely, if $k=0$ then no +weighting is given. + +**What problem it aims to solve:** \ +The goal of approximate quantum compiling (AQC) is to produce a circuit that approximately prepares +a target state **with less depth** than the original circuit. The aim of this heuristic is to make +ADAPT-AQC depth-aware, so that e.g., the same pairs of qubits are not repeatedly picked if they are +only +marginally higher entanglement than other pairs that haven't been used. Ultimately, compiling +with a larger exponent produces shallower solutions, at the cost of longer compiling times. + +## reuse_priority_mode + +**What is it:** \ +The reuse priority system is used to de-prioritise qubits that were recently acted on. When +`reuse_priority_mode="pair"`, the priority of a pair of qubits (a, b) is calculated as + +$$P_r = 1-2^{\frac{-l}{k}},$$ + +where $l$ is the number of layers since that pair had a layer applied to it. + +When `reuse_priority_mode="qubit"`, the priority is instead calculated as + +$$P_r = \mathrm{min}\[1-2^{\frac{-(l_a + 1)}{k}}, 1-2^{\frac{-(l_b + 1)}{k}}\],$$ + +where $l_a$ or $l_b$ is the number of layers since qubit a or b has been acted on respectively. Note +that in both cases, the priority of the most recently used qubit pair is set to -1 so that it is +never chosen. Additionally, the priority of a pair that has never been used is manually set to 1, so +that it receives maximum priority. + +**What problem it aims to solve:** \ +This heuristic is meant to reflect that, given a pair of qubits (a, b) was recently acted on, the +depth of the compiled solution will increase if _either_ a or b are acted on in a new layer. +Previously, the "pair" option was the only type of reuse priority in ADAPT-AQC, leading often to +solutions where successive layers might act on pairs (a, a+1), (a+1, a+2), (a+2, a+3)... These +branch-like structures significantly increase the depth of the solution. + +## rotosolve_frequency + +**What is it:** \ +The main optimisation algorithms used by ADAPT-AQC are the Rotoselect and Rotosolve algorithms, more +details of which can be found at https://quantum-journal.org/papers/q-2021-01-28-391/. Put simply, +the Roto algorithms use sequential optimisation. Given a set of $L$ parameterised gates, the +procedure +works by fixing $L-1$ of the gates and varying the remaining one to minimise the cost function. +This is then repeated for the remaining $L-1$ gates, unfixing one at a time and fixing the others. +As the changing of later gates in the layer will affect the loss landscape of the first gates, we +repeatedly cycle over all rotation gates until a termination criteria is reached. + +`rotosolve_frequency` defines how often the ansatz is optimised using specifically the Rotosolve +algorithm, which only changes the angles of parameterised gates. Specifically, Rotosolve is called +after every `rotosolve_frequency` number of layers have been added. In the context of ADAPT-AQC, it +is +notable that _only rotosolve_ has the ability to modify previous layers. Specifically, the last +`AdaptConfig.max_layers_to_modify` layers will be optimised using Rotosolve. This makes it an +expensive step but often necessary to reach convergence. + +NOTE Setting the value `rotosolve_frequency=0` will disable rotosolve. This can lead to a large +performance improvement when using the matrix product state (MPS) backends, since the guarantee +that previous layers won't be modified allows us to cache the state of the system during evolution. + +**What problem it aims to solve:** \ +The use of Rotosolve reflects the idea that after adding layers, the optimal parameters of +previous layers may have changed. Thus it may be more efficient (in terms of final circuit depth) +to attempt to re-optimise previous layers than to only add new layers. As such, when not using +Rotosolve, generally the solution will be deeper. + +# AdaptCompiler() + +## coupling_map + +**What is it:** \ +A user specified list of tuples, each of which represents a connection between qubits. ADAPT-AQC +will be +restricted to only adding CNOT gates between pairs which are in this coupling map. + +**What problem it aims to solve:** \ +Often we want to run the ADAPT-AQC solution on a specific connectivity hardware (e.g., heavy hex). +Without +this option, it would be possible to convert any solution to a connectivity via SWAP gates, however +this can be extremely expensive in terms of number of gates. This option exists to allow ADAPT-AQC +to +restrict the solution space during compiling. + +## custom_layer_2q_gate + +**What is it:** \ +A Qiskit `QuantumCircuit` to be used as the ansatz layers. + +**What problem it aims to solve:** \ +ADAPT-AQC uses by default a thinly-dressed CNOT gate (i.e., CNOT surrounded by 4 single qubit +rotations). +This ansatz is not universal and has not been shown to be objectively better than others, but is a +heuristic that originally worked. As such it may be valuable to change what ansatz layer is used. + +## starting_circuit + +**What is it:** \ +This is a `QuantumCircuit` that will be used as a set of initial fixed gates for the compiled +solution $\hat{V}$. Because during ADAPT-AQC we are variationally optimising $\hat{V}^\dagger$, the +inverse of `starting_circuit` will be placed at the end of the ansatz. + +**What problem it aims to solve:** \ +`starting_circuit` is a useful heuristic when we have some knowledge of the structure of the +solution. A good example of what this aims to solve is when a compilation target includes +a distinct state preparation step. For example, consider compiling the evolution of a spin-chain +starting in the Neel state. The compiling problem is much more efficient if ADAPT-AQC does not need +to +learn to start by applying an X gate to every other qubit. + +## local_cost_function + +**What is it:**\ +Normally, ADAPT-AQC uses a a cost $C_\mathrm{LET} = 1- |\langle 0 | V^\dagger U|0\rangle|^2$. The +fidelity +term, +as defined in section IIIB of https://arxiv.org/abs/1908.04416, is generated using the Loschmidt +Echo Test (LET), which is the formal name for acting $U|0\rangle$ followed by $V^\dagger$ to get +the fidelity. We note that the cost is global with respect to the Hilbert space, since the overlap +with the $|00...0\rangle$ state spans the full state vector. + +By contrast, when setting `local_cost_function=True`, ADAPT-AQC will use a cost derived from the +Local +Loschmidt Echo Test (LLET). Specifically, the cost is defined as + +$$C_\mathrm{LLET} = 1 - \frac{1}{n} \sum_{j=1}^{n} \langle 0|\rho^j|0\rangle,$$ + +where the second term can be +recognised as the sum of the probabilities that each qubit is in the $|0\rangle$ state. Since the +cost function does not span the entire Hilbert space, it is described as local. + +**What problem it aims to solve:** \ +The distinction between global and local cost functions is very important in the context of +trainability and barren plateaus (see https://www.nature.com/articles/s41467-021-21728-w), where +a global cost function is difficult to train for large numbers of qubits. By contrast the local +cost function is trainable. However, we note that $C_\mathrm{LLET} <= C_\mathrm{LET}$, meaning +that ADAPT-AQC may not have achieved the desired global fidelity just because the local cost +converges. + +## initial_single_qubit_layer + +**What is it:** \ +When `initial_single_qubit_layer=True`, the first layer of the ADAPT-AQC ansatz will be +a trainable single-qubit rotation on each qubit. Since this layer will be optimised by Rotoselect, +this means that both the angles and the bases of rotations can be modified. Note that since this +is the first layer of the ADAPT-AQC ansatz $V^\dagger$, it will end up being the final layer of the +returned solution $V$. So we can think of this feature as adding a trainable basis change before +measuring the cost function. + +**What problem it aims to solve:** \ +ADAPT-AQC only applies layers in two-qubit blocks, which means that in certain situations ADAPT-AQC +won't be +able to find the optimal depth solution. A good example of this is when only a subset of the +qubits are entangled. To demonstrate why, for the extreme case of compiling the $n$ qubit +$|++..+\rangle$ state, ADAPT-AQC without this feature will need to apply $n$ CNOT gates. By +contrast, +with `initial_single_qubit_layer=True`, a solution can be found in depth 1 with no CNOT gates. +It is possible the same issue can arise for any target state, if during compiling ADAPT-AQC is left +with an intermediate low-entangled state. + +## AdaptCompiler.compile_in_parts() + +**What is it:** \ +Compiling in parts, (also called ladder-ADAPT-AQC), is the idea of splitting up a +circuit into chunks that we compile sequentially. For example, given a depth 50 circuit $U_ +{50}|0\rangle$, we can compile the first 10 depth of gates $U_{0-10}|0\rangle$ to produce +$V_{0-10}^\dagger|0\rangle \approx U_{0-10}|0\rangle$. This can then be used to construct a new +target $U_{11-20}V_{0-10}^\dagger|0\rangle$. + +A particularly good example of this is for time evolution circuits. Here, we start by compiling only +the first Trotter step. Once we have a solution $V^\dagger_1$, we append a Trotter step to it and +use it as the target state for compiling 2 Trotter steps worth of evolution. We can "ladder" this +all the way to the desired number of Trotter steps. This is shown in Fig.7 +of https://arxiv.org/abs/2002.04612. + +When applied to time dynamics, compiling in parts is also referred to as restarted quantum dynamics +(https://arxiv.org/abs/1910.06284), iterative variational Trotter +compression (https://arxiv.org/abs/2404.10044) and compressed quantum +circuits (https://arxiv.org/abs/2008.10322). There are inevitably more references using the same +idea. + +**What problem it aims to solve:** \ +There are two key benefits to compiling in parts. Firstly, compiling smaller chunks makes each +individual optimisation problem easier and less likely to suffer from trainability issues. If +we consider the extreme case of compiling one gate at a time, it is clear that compiling only +needs to learn the application of one more gate on the starting state. + +Secondly, if running approximate quantum compiling on real quantum hardware, compiling in parts +allows one to limit the depth of any circuit executed. For example, if the target circuit has a +depth of 20, but a device is limited to depth 10 before noise ruins the computation, one can compile +in blocks of 5 depth at a time. If successful, $V^\dagger$ will never be deeper than $U$, meaning +that the compiling circuit $V^\dagger U |0\rangle$ will never be more than 10 depth. + +There are two key drawbacks to compiling in parts. Firstly, we need to solve several sequential +compilation problems, which can take longer than compiling the entire circuit at once (if possible). +Secondly, the approximation error of each individual solution will multiply every time it is used +as the input for the next compiling. Thus the approximation error of the final solution grows +exponentially. For example, if we compile 18 sub-circuits one at a time, with a sufficient +overlap for each one of $0.99$, the overlap of the final solution would be $0.99^{18} = 0.83$. Thus, +one +would need to instead use a much higher sufficient overlap of 0.9995 for each sub-ciruit to get +the desired final overlap of $0.99$. \ No newline at end of file diff --git a/utils/adapt-aqc/examples/advanced_mps_example.py b/utils/adapt-aqc/examples/advanced_mps_example.py new file mode 100644 index 0000000000000000000000000000000000000000..57f92474f37982b76f0cad865d87d3fc6686892f --- /dev/null +++ b/utils/adapt-aqc/examples/advanced_mps_example.py @@ -0,0 +1,66 @@ +""" +Example script for running ADAPT-AQC recompilation using more advanced options +""" + +import logging +import matplotlib.pyplot as plt + +from tenpy import SpinChain, MPS +from tenpy.algorithms import dmrg + +from adaptaqc.backends.aer_mps_backend import AerMPSBackend, mps_sim_with_args +from adaptaqc.compilers import AdaptCompiler, AdaptConfig +from adaptaqc.utils.ansatzes import identity_resolvable +from adaptaqc.utils.utilityfunctions import tenpy_to_qiskit_mps + +logging.basicConfig() +logger = logging.getLogger("adaptaqc") +logger.setLevel(logging.INFO) + +# Generate a ground state of the XXZ model using TenPy +l = 20 +model_params = dict( + S=0.5, L=l, Jx=1.0, Jy=1.0, Jz=5.0, hz=1.0, bc_MPS="finite", conserve="None" +) +model = SpinChain(model_params) + +psi = MPS.from_product_state( + model.lat.mps_sites(), ["up", "down"] * (l // 2), bc=model_params["bc_MPS"] +) + +# Run the DMRG algorithm to obtain the ground state +dmrg_params = {"trunc_params": {"trunc_cut": 1e-4}} +dmrg_engine = dmrg.TwoSiteDMRGEngine(psi, model, dmrg_params) +E, psi = dmrg_engine.run() +logger.info(f"Ground state created with maximum bond dimension {max(psi.chi)}") + +# Convert it to a format compatible with the Qiskit Aer MPS simulator +qiskit_mps = tenpy_to_qiskit_mps(psi) + +# Set compiler to use the general gradient method as laid out in https://arxiv.org/abs/2503.09683 +config = AdaptConfig( + method="general_gradient", cost_improvement_num_layers=1e3, rotosolve_frequency=10 +) + +# Create an instance of Qiskit's MPS simulator with a specified truncation threshold +qiskit_mps_sim = mps_sim_with_args(mps_truncation_threshold=1e-8) + +# Create an AQCBackend object +backend = AerMPSBackend(simulator=qiskit_mps_sim) + +# Create a compiler with the target to be an MPS rather than a circuit +adapt_compiler = AdaptCompiler( + target=qiskit_mps, + backend=backend, + adapt_config=config, + starting_circuit="tenpy_product_state", # Start compiling from best χ=1 compression of target + custom_layer_2q_gate=identity_resolvable(), # Use ansatz from https://arxiv.org/abs/2503.09683 +) + +result = adapt_compiler.compile() +approx_circuit = result.circuit +print(f"Overlap between circuits is {result.overlap}") + +# Draw the circuit that prepares the target random MPS +approx_circuit.draw(output="mpl", fold=-1) +plt.show() diff --git a/utils/adapt-aqc/examples/advanced_sv_example.py b/utils/adapt-aqc/examples/advanced_sv_example.py new file mode 100644 index 0000000000000000000000000000000000000000..18a855878cddebc49a7d20305f70a2987d3c9c38 --- /dev/null +++ b/utils/adapt-aqc/examples/advanced_sv_example.py @@ -0,0 +1,61 @@ +""" +Example script for running ADAPT-AQC recompilation using more advanced options +""" + +import logging + +from qiskit import QuantumCircuit, transpile +from qiskit.circuit.random import random_circuit + +from adaptaqc.compilers import AdaptCompiler, AdaptConfig + +logging.basicConfig() +logger = logging.getLogger("adaptaqc") +logger.setLevel(logging.INFO) + +n = 4 +state_prep_circuit = QuantumCircuit(n) +state_prep_circuit.h(range(n)) + +# Create a random circuit starting with a layer of hadamard gates +qc = state_prep_circuit.compose(random_circuit(n, 16, 2, seed=0)) + +config = AdaptConfig( + # We expect the solution to take longer to converge, so decrease the threshold for exiting + # early. + cost_improvement_tol=1e-5, + # Run Rotosolve only every 10th layer to reduce computational cost. + rotosolve_frequency=10, + # Choose Rotosolve to modify only the last 10 layers. + max_layers_to_modify=10, + # Setting this value > 0 prioritises not using the same qubit pairs too often. + reuse_exponent=1, + # Increase the amount the cost needs to decrease by to terminate Rotosolve. This stops spending + # too much time fine-tuning the angles. + rotosolve_tol=1e-2, +) + +# Since we know the solution starts with Hadamards, we can pass this information into ADAPT-AQC +starting_circuit = state_prep_circuit + +adapt_compiler = AdaptCompiler( + target=qc, + adapt_config=config, + starting_circuit=starting_circuit, + initial_single_qubit_layer=True, +) + +result = adapt_compiler.compile() +approx_circuit = result.circuit +print(f"Overlap between circuits is {result.overlap}") + +# Transpile the original circuits to the common basis set with maximum Qiskit optimization +qc_in_basis_gates = transpile( + qc, basis_gates=["ry", "rz", "rx", "u3", "cx"], optimization_level=3 +) +print("Original circuit gates:", qc_in_basis_gates.count_ops()) +print("Original circuit depth:", qc_in_basis_gates.depth()) + +# Compare with compiled circuit +print("Compiled circuit gates:", approx_circuit.count_ops()) +print("Compiled circuit depth:", approx_circuit.depth()) diff --git a/utils/adapt-aqc/examples/readme_example.py b/utils/adapt-aqc/examples/readme_example.py new file mode 100644 index 0000000000000000000000000000000000000000..66ace5441f19a02f95a963152c0af06ba14551a6 --- /dev/null +++ b/utils/adapt-aqc/examples/readme_example.py @@ -0,0 +1,64 @@ +import logging + +from qiskit import QuantumCircuit, transpile +from qiskit.circuit.random import random_circuit + +from adaptaqc.compilers import AdaptCompiler, AdaptConfig +from adaptaqc.utils.entanglement_measures import EM_TOMOGRAPHY_CONCURRENCE + +logging.basicConfig() +logger = logging.getLogger("adaptaqc") +logger.setLevel(logging.INFO) + +# Setup the circuit +qc = QuantumCircuit(3) +qc.rx(1.23, 0) +qc.cx(0, 1) +qc.ry(2.5, 1) +qc.rx(-1.6, 2) +qc.ccx(2, 1, 0) + +# Compile +compiler = AdaptCompiler(qc) +result = compiler.compile() +compiled_circuit = result.circuit + +# See the compiled output +print(f'{"-" * 10} ORIGINAL CIRCUIT {"-" * 10}') +print(qc) +print(f'{"-" * 10} RECOMPILED CIRCUIT {"-" * 10}') +print(compiled_circuit) + +qc = random_circuit(5, 5, seed=1) + +for i, (instr, _, _) in enumerate(qc.data): + if instr.name == "id": + qc.data.__delitem__(i) + +# Compile +config = AdaptConfig(sufficient_cost=1e-2) +compiler = AdaptCompiler( + qc, entanglement_measure=EM_TOMOGRAPHY_CONCURRENCE, adapt_config=config +) +result = compiler.compile() +print(result) +compiled_circuit = result.circuit + +# See the original circuit +print(f'{"-" * 10} ORIGINAL CIRCUIT {"-" * 10}') +print(qc) + +# See the compiled solution +print(f'{"-" * 10} RECOMPILED CIRCUIT {"-" * 10}') +print(compiled_circuit) + +# Transpile the original circuits to the common basis set +qc_in_basis_gates = transpile( + qc, basis_gates=["u1", "u2", "u3", "cx"], optimization_level=3 +) +print(qc_in_basis_gates.count_ops()) +print(qc_in_basis_gates.depth()) + +# Compare with compiled circuit +print(compiled_circuit.count_ops()) +print(compiled_circuit.depth()) diff --git a/utils/adapt-aqc/examples/simple_mps_example.py b/utils/adapt-aqc/examples/simple_mps_example.py new file mode 100644 index 0000000000000000000000000000000000000000..6c1812e845edb16de12ad0ae55d88d72c5b887e0 --- /dev/null +++ b/utils/adapt-aqc/examples/simple_mps_example.py @@ -0,0 +1,33 @@ +""" +Example script for running ADAPT-AQC recompilation using a Matrix Product State (MPS) backend. +MPS is an alternative quantum state representation to state-vector, and is better suited to handle +large, low-entanglement states. +""" + +import logging + +from qiskit import QuantumCircuit + +from adaptaqc.backends.aer_mps_backend import AerMPSBackend +from adaptaqc.compilers import AdaptCompiler + +logging.basicConfig() +logger = logging.getLogger("adaptaqc") +logger.setLevel(logging.INFO) + +# -------------------------------------------------------------------------------- +# Very simple MPS example +# Create a large circuit where only some qubits are entangled +n = 50 +qc = QuantumCircuit(n) +qc.h(0) +qc.cx(0, 1) +qc.h(2) +qc.cx(2, 3) +qc.h(range(4, n)) + +# Create compiler with the default MPS simulator, which has very minimal truncation. +adapt_compiler = AdaptCompiler(qc, backend=AerMPSBackend()) + +result = adapt_compiler.compile() +print(f"Overlap between circuits is {result.overlap}") diff --git a/utils/adapt-aqc/examples/simple_sv_example.py b/utils/adapt-aqc/examples/simple_sv_example.py new file mode 100644 index 0000000000000000000000000000000000000000..c3fa59be8b117d20398b1cdcfa25d32ab95e829e --- /dev/null +++ b/utils/adapt-aqc/examples/simple_sv_example.py @@ -0,0 +1,25 @@ +import logging + +import adaptaqc.utils.circuit_operations as co +from adaptaqc.compilers import AdaptCompiler + +logging.basicConfig() +logger = logging.getLogger("adaptaqc") +logger.setLevel(logging.INFO) + +# Create circuit creating a random initial state +qc = co.create_random_initial_state_circuit(4) + +adapt_compiler = AdaptCompiler(qc) + +result = adapt_compiler.compile() +approx_circuit = result.circuit +print(f"Overlap between circuits is {result.overlap}") +print(f'{"-" * 32}') +print(f'{"-" * 10}OLD CIRCUIT{"-" * 10}') +print(f'{"-" * 32}') +print(qc) +print(f'{"-" * 32}') +print(f'{"-" * 10}ADAPT-AQC CIRCUIT{"-" * 10}') +print(f'{"-" * 32}') +print(approx_circuit) diff --git a/utils/adapt-aqc/requirements.txt b/utils/adapt-aqc/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..9c558e357c41674e39880abb6c3209e539de42e2 --- /dev/null +++ b/utils/adapt-aqc/requirements.txt @@ -0,0 +1 @@ +. diff --git a/utils/adapt-aqc/setup.py b/utils/adapt-aqc/setup.py new file mode 100644 index 0000000000000000000000000000000000000000..20ef7d6342e6d997ec8421a20235e01a015d8b12 --- /dev/null +++ b/utils/adapt-aqc/setup.py @@ -0,0 +1,28 @@ +from setuptools import find_packages, setup + +setup( + name="adaptaqc", + version="1.0.0", + author="Ben Jaderberg, George Pennington, Abhishek Agarwal, Kate Marshall, Lewis Anderson", + author_email="benjamin.jaderberg@ibm.com, george.penngton@stfc.com, kate.marshall@ibm.com, lewis.anderson@ibm.com", + packages=find_packages(), + scripts=[], + url="https://github.com/todo/", + license="LICENSE", + description="A package for implementing the Adaptive \ + Approximate Quantum Compiling (ADAPT-AQC) algorithm", + long_description=open("README.md").read(), + long_description_content_type="text/markdown", + install_requires=[ + "qiskit>=1.3.1", + "qiskit_aer>=0.16.0", + "qiskit_experiments>=0.6.1", + "numpy", + "scipy", + "scipy", + "openfermion~=1.6", + "sympy", + "aqc_research @ git+ssh://git@github.com/bjader/aqc-research.git", + "physics-tenpy~=1.0.2", + ], +) diff --git a/utils/adapt-aqc/test/__init__.py b/utils/adapt-aqc/test/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/utils/adapt-aqc/test/recompilers/__init__.py b/utils/adapt-aqc/test/recompilers/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/utils/adapt-aqc/test/recompilers/test_adapt_compiler.py b/utils/adapt-aqc/test/recompilers/test_adapt_compiler.py new file mode 100644 index 0000000000000000000000000000000000000000..f92fc04529b6cc9cb9087fbe8b02bd2b12b1c805 --- /dev/null +++ b/utils/adapt-aqc/test/recompilers/test_adapt_compiler.py @@ -0,0 +1,1543 @@ +import copy +import logging +import os +import pickle +import random +import shutil +import tempfile +from unittest import TestCase +from unittest.mock import patch + +import numpy as np +from aqc_research.model_sp_lhs.trotter.trotter import trotter_circuit +from aqc_research.mps_operations import mps_from_circuit +from qiskit import ClassicalRegister, QuantumCircuit, QuantumRegister +from qiskit.compiler import transpile +from qiskit.quantum_info import Statevector +from tenpy.algorithms import tebd +from tenpy.models import XXZChain +from tenpy.networks.mps import MPS + +import adaptaqc.utils.ansatzes as ans +import adaptaqc.utils.circuit_operations as co +from adaptaqc.backends.python_default_backends import SV_SIM, MPS_SIM, QASM_SIM +from adaptaqc.compilers import AdaptConfig, AdaptCompiler +from adaptaqc.utils.constants import DEFAULT_SUFFICIENT_COST +from adaptaqc.utils.entanglement_measures import EM_TOMOGRAPHY_NEGATIVITY +from adaptaqc.utils.utilityfunctions import multi_qubit_gate_depth, tenpy_to_qiskit_mps + + +def create_initial_ansatz(): + initial_ansatz = QuantumCircuit(4) + initial_ansatz.ry(0, [0, 1, 2, 3]) + initial_ansatz.cx(0, 1) + initial_ansatz.cx(1, 2) + initial_ansatz.cx(2, 3) + initial_ansatz.rx(0, [0, 1, 2, 3]) + + return initial_ansatz + + +class TestAdapt(TestCase): + def test_adapt_procedure_sv(self): + qc = co.create_random_initial_state_circuit(3, seed=1) + qc = co.unroll_to_basis_gates(qc) + + adapt_compiler = AdaptCompiler( + qc, backend=SV_SIM, adapt_config=AdaptConfig(sufficient_cost=1e-2) + ) + + result = adapt_compiler.compile() + approx_circuit = result.circuit + + overlap = co.calculate_overlap_between_circuits(approx_circuit, qc) + assert overlap > 1 - DEFAULT_SUFFICIENT_COST + + def test_adapt_procedure_qasm(self): + qc = co.create_random_initial_state_circuit(3, seed=1) + qc = co.unroll_to_basis_gates(qc) + + shots = 1e4 + adapt_compiler_qasm = AdaptCompiler( + qc, backend=QASM_SIM, execute_kwargs={"shots": shots} + ) + + result_qasm = adapt_compiler_qasm.compile() + approx_circuit_qasm = result_qasm.circuit + overlap = co.calculate_overlap_between_circuits(approx_circuit_qasm, qc) + assert overlap > 1 - DEFAULT_SUFFICIENT_COST - 5 / np.sqrt(shots) + + def test_adapt_procedure_mps(self): + qc = co.create_random_initial_state_circuit(3, seed=1) + qc = co.unroll_to_basis_gates(qc) + + shots = 1e4 + adapt_compiler_mps = AdaptCompiler( + qc, backend=MPS_SIM, execute_kwargs={"shots": shots} + ) + + result_mps = adapt_compiler_mps.compile() + approx_circuit_mps = result_mps.circuit + + overlap = co.calculate_overlap_between_circuits(approx_circuit_mps, qc) + assert overlap > 1 - DEFAULT_SUFFICIENT_COST - 5 / np.sqrt(shots) + + def test_adapt_procedure_when_input_mps_directly(self): + qc = co.create_random_initial_state_circuit(3) + qc = co.unroll_to_basis_gates(qc) + mps = mps_from_circuit(qc.copy(), sim=MPS_SIM.simulator) + + # Input MPS for recompilation rather than QuantumCircuit + compiler = AdaptCompiler(mps, backend=MPS_SIM) + + result = compiler.compile() + approx_circuit = result.circuit + + overlap = co.calculate_overlap_between_circuits(approx_circuit, qc) + assert overlap > 1 - DEFAULT_SUFFICIENT_COST + + def test_GHZ(self): + qc = QuantumCircuit(5) + + qc.h(0) + for i in range(4): + qc.cx(i, i + 1) + + qc = co.unroll_to_basis_gates(qc) + + adapt_compiler = AdaptCompiler( + qc, backend=SV_SIM, adapt_config=AdaptConfig(sufficient_cost=1e-2) + ) + + result = adapt_compiler.compile() + approx_circuit = result.circuit + + overlap = co.calculate_overlap_between_circuits(approx_circuit, qc) + assert overlap > 1 - DEFAULT_SUFFICIENT_COST + + def test_exact_overlap_close_to_approx_overlap(self): + qc = co.create_random_initial_state_circuit(3) + qc = co.unroll_to_basis_gates(qc) + + adapt_compiler = AdaptCompiler(qc) + + result = adapt_compiler.compile() + approx_circuit = result.circuit + approx_overlap = result.overlap + exact_overlap = result.exact_overlap + self.assertAlmostEqual(approx_overlap, exact_overlap, delta=1e-2) + + def test_exact_overlap_calculated_correctly(self): + qc = co.create_random_initial_state_circuit(3) + qc = co.unroll_to_basis_gates(qc) + + adapt_compiler = AdaptCompiler(qc) + + result = adapt_compiler.compile() + approx_circuit = result.circuit + exact_overlap1 = result.exact_overlap + exact_overlap2 = co.calculate_overlap_between_circuits(approx_circuit, qc) + self.assertAlmostEqual(exact_overlap1, exact_overlap2, delta=1e-2) + + def test_local_cost_sv(self): + qc = co.create_random_initial_state_circuit(3) + qc = co.unroll_to_basis_gates(qc) + adapt_config = AdaptConfig(cost_improvement_num_layers=10) + + adapt_compiler = AdaptCompiler( + qc, optimise_local_cost=True, backend=SV_SIM, adapt_config=adapt_config + ) + result = adapt_compiler.compile() + cost = adapt_compiler.evaluate_cost() + assert cost < DEFAULT_SUFFICIENT_COST + + def test_custom_layer_gate(self): + from qiskit import QuantumCircuit + + from adaptaqc.utils.fixed_ansatz_circuits import number_preserving_ansatz + + # Initialize to a supervision of states with bit sum 2 + statevector = [ + 0, + 0, + 0, + -((1 / 3) ** 0.5), + 0, + 1j * (1 / 3) ** 0.5, + -1 * (1 / 3) ** 0.5, + 0, + ] + qc = co.initial_state_to_circuit(statevector) + + initial_circuit = QuantumCircuit(3) + initial_circuit.x(0) + initial_circuit.x(1) + + adapt_compiler = AdaptCompiler( + qc, + custom_layer_2q_gate=number_preserving_ansatz(2, 1), + starting_circuit=initial_circuit, + ) + + result = adapt_compiler.compile() + approx_circuit = result.circuit + + overlap = co.calculate_overlap_between_circuits(approx_circuit, qc) + assert overlap > 1 - DEFAULT_SUFFICIENT_COST + + def test_with_initial_ansatz(self): + from adaptaqc.utils.fixed_ansatz_circuits import hardware_efficient_circuit + + qc = hardware_efficient_circuit(3, "rxrz", 3) + + qc_mod = qc.copy() + qc_mod.cx(0, 1) + qc_mod.h(1) + qc_mod.cx(1, 2) + + adapt_compiler = AdaptCompiler(qc_mod) + + result = adapt_compiler.compile(initial_ansatz=qc) + approx_circuit = result.circuit + + overlap = co.calculate_overlap_between_circuits(approx_circuit, qc_mod) + assert overlap > 1 - DEFAULT_SUFFICIENT_COST + + def test_expectation_method(self): + qc = co.create_random_initial_state_circuit(3) + qc = co.unroll_to_basis_gates(qc) + config = AdaptConfig(method="expectation") + + adapt_compiler = AdaptCompiler(qc, adapt_config=config) + result = adapt_compiler.compile() + approx_circuit = result.circuit + overlap = co.calculate_overlap_between_circuits(approx_circuit, qc) + assert overlap > 1 - DEFAULT_SUFFICIENT_COST + + def test_basic_methods(self): + qc = co.create_random_initial_state_circuit(3) + qc = co.unroll_to_basis_gates(qc) + config = AdaptConfig(method="basic") + + adapt_compiler = AdaptCompiler(qc, adapt_config=config) + result = adapt_compiler.compile() + approx_circuit = result.circuit + overlap = co.calculate_overlap_between_circuits(approx_circuit, qc) + assert overlap > 1 - DEFAULT_SUFFICIENT_COST + + def test_random_methods(self): + qc = co.create_random_initial_state_circuit(3) + qc = co.unroll_to_basis_gates(qc) + config = AdaptConfig(method="random") + + adapt_compiler = AdaptCompiler(qc, adapt_config=config) + result = adapt_compiler.compile() + approx_circuit = result.circuit + overlap = co.calculate_overlap_between_circuits(approx_circuit, qc) + assert overlap > 1 - DEFAULT_SUFFICIENT_COST + + def test_given_circuit_with_non_basis_gates_when_recompiling_then_no_error(self): + qc1 = QuantumCircuit(2) + qc1.h([0, 1]) + qc2 = QuantumCircuit(2) + qc2.x(1) + qc2.append(qc1.to_instruction(), qc2.qregs[0]) + compiler = AdaptCompiler(qc2) + compiler.compile() + + def test_given_starting_circuit_when_compile_with_debug_logging_then_happy(self): + logging.basicConfig() + logging.getLogger("adaptaqc").setLevel(logging.DEBUG) + + n = 3 + + starting_ansatz_circuit = QuantumCircuit(n) + starting_ansatz_circuit.x(range(0, n, 2)) + + qc = co.create_random_initial_state_circuit(n) + + compiler = AdaptCompiler(qc, starting_circuit=starting_ansatz_circuit) + + compiler.compile() + logging.getLogger("adaptaqc").setLevel(logging.WARNING) + + def test_given_starting_circuit_when_compile_then_solution_starts_with_it(self): + n = 2 + starting_ansatz_circuit = QuantumCircuit(n) + starting_ansatz_circuit.x(0) + + qc = co.create_random_initial_state_circuit(n) + + for boolean in [False, True]: + compiler = AdaptCompiler( + qc, + starting_circuit=starting_ansatz_circuit, + initial_single_qubit_layer=boolean, + ) + + result = compiler.compile() + compiled_qc: QuantumCircuit = result.circuit + del compiled_qc.data[1:] + overlap = ( + np.abs( + np.dot( + Statevector(compiled_qc).conjugate(), + Statevector(starting_ansatz_circuit), + ) + ) + ** 2 + ) + self.assertAlmostEqual(overlap, 1) + + def test_given_two_registers_when_recompiling_then_no_error(self): + qr1 = QuantumRegister(2) + qr2 = QuantumRegister(2) + qc = QuantumCircuit(qr1, qr2) + compiler = AdaptCompiler(qc) + result = compiler.compile() + + def test_given_two_registers_when_recompiling_then_register_names_preserved(self): + qr1 = QuantumRegister(2, "reg1") + qr2 = QuantumRegister(2, "reg2") + qc = QuantumCircuit(qr1, qr2) + qc.h(1) + qc.cx(1, 2) + qc.x(3) + compiler = AdaptCompiler(qc) + result = compiler.compile() + final_circuit = result.circuit + assert final_circuit.qregs == qc.qregs + + def test_given_circuit_with_cregs_when_recompiling_then_no_error(self): + qreg = QuantumRegister(2) + creg = ClassicalRegister(2) + qc = QuantumCircuit(qreg, creg) + + compiler = AdaptCompiler(qc) + compiler.compile() + + def test_given_circuit_with_cregs_when_recompiling_then_register_names_preserved( + self, + ): + qreg = QuantumRegister(2) + creg = ClassicalRegister(2) + qc = QuantumCircuit(qreg, creg) + + compiler = AdaptCompiler(qc) + result = compiler.compile() + final_circuit = result.circuit + assert final_circuit.cregs == qc.cregs + + # TODO this test fails when if setting initial_single_qubit_layer=True + # TODO Not priority fix as unusual case of |00..0> target state and circ with measurements. + def test_given_circuit_with_measurements_when_recompiling_then_no_error(self): + qreg = QuantumRegister(2) + creg = ClassicalRegister(2) + qc = QuantumCircuit(qreg, creg) + qc.cx(0, 1) + qc.measure(0, 0) + compiler = AdaptCompiler(qc, initial_single_qubit_layer=False) + compiler.compile() + + def test_circuit_output_regularly_saved(self): + qc = co.create_random_initial_state_circuit(3, seed=1) + qc = co.unroll_to_basis_gates(qc) + + shots = 1e4 + adapt_compiler = AdaptCompiler( + qc, + backend=MPS_SIM, + execute_kwargs={"shots": shots}, + save_circuit_history=True, + ) + + result = adapt_compiler.compile() + self.assertTrue( + len(result.circuit_history) == len(result.global_cost_history) - 1 + ) + self.assertTrue( + len(result.circuit_history[-1]) > len(result.circuit_history[-2]) + ) + + def test_circuit_output_not_saved_when_not_flagged(self): + qc = co.create_random_initial_state_circuit(3, seed=1) + qc = co.unroll_to_basis_gates(qc) + + shots = 1e4 + adapt_compiler = AdaptCompiler( + qc, backend=MPS_SIM, execute_kwargs={"shots": shots} + ) + + result = adapt_compiler.compile() + self.assertFalse(len(result.circuit_history)) + + # TODO See above + def test_given_circuit_with_one_measurement_when_recompiling_then_preserve_measurement( + self, + ): + qreg = QuantumRegister(2) + creg = ClassicalRegister(2) + qc = QuantumCircuit(qreg, creg) + qc.cx(0, 1) + qc.measure(0, 0) + compiler = AdaptCompiler(qc, initial_single_qubit_layer=False) + result = compiler.compile() + assert result.circuit.data[-1] == qc.data[-1] + + # TODO See above + def test_given_circuit_with_multi_measurement_when_recompiling_then_preserve_measurement( + self, + ): + num_measurements = 3 + qreg = QuantumRegister(num_measurements + 2) + creg = ClassicalRegister(num_measurements + 2) + qc = QuantumCircuit(qreg, creg) + qc.cx(0, 1) + for i in range(num_measurements): + qc.measure(i, i) + compiler = AdaptCompiler(qc, initial_single_qubit_layer=False) + result = compiler.compile() + assert result.circuit.data[-num_measurements:] == qc.data[-num_measurements:] + + def test_given_compiler_when_float_cost_improvement_num_layers_then_no_error( + self, + ): + qc = co.create_random_initial_state_circuit(3) + config = AdaptConfig(cost_improvement_num_layers=4.0, cost_improvement_tol=1) + compiler = AdaptCompiler(qc, adapt_config=config) + compiler.compile() + + def test_given_initial_single_qubit_layer_when_compiling_then_then_good_solution( + self, + ): + qc = co.create_random_initial_state_circuit(3) + compiler = AdaptCompiler(qc, initial_single_qubit_layer=True) + result = compiler.compile() + approx_circuit = result.circuit + overlap = co.calculate_overlap_between_circuits(approx_circuit, qc) + self.assertTrue(overlap > 1 - DEFAULT_SUFFICIENT_COST) + + def test_given_isql_when_compiling_zero_state_then_zero_depth_solution(self): + qc = QuantumCircuit(3) + compiler = AdaptCompiler(qc, initial_single_qubit_layer=True) + result = compiler.compile() + approx_circuit = result.circuit + self.assertEqual(approx_circuit.depth(), 0, "Depth of solution should be zero") + + def test_given_isql_when_compiling_then_ansatz_starts_with_n_single_qubit_gates( + self, + ): + n = 3 + qc = co.create_random_initial_state_circuit(n) + config = AdaptConfig(max_layers=2) + compiler = AdaptCompiler( + qc, adapt_config=config, initial_single_qubit_layer=True + ) + result = compiler.compile() + + ansatz_start, ansatz_end = compiler.variational_circuit_range() + ansatz = compiler.full_circuit[ansatz_start:ansatz_end] + for instr in ansatz[:n]: + self.assertIn(instr[0].name, ["rx", "ry", "rz"]) + + def test_given_isql_when_compiling_then_results_object_elements_correct_length( + self, + ): + qc = QuantumCircuit(3) + compiler = AdaptCompiler(qc, initial_single_qubit_layer=True) + result = compiler.compile() + self.assertTrue( + len(result.global_cost_history) - 1 + == len(result.entanglement_measures_history) + == len(result.e_val_history) + == len(result.qubit_pair_history) + == len(result.method_history) + ) + + def test_given_adapt_mode_when_compile_circuit_with_very_small_entanglement_then_expectation_method_used( + self, + ): + qc = QuantumCircuit(2) + qc.h(0) + qc.crx(1e-15, 0, 1) + + compiler = AdaptCompiler(qc, entanglement_measure=EM_TOMOGRAPHY_NEGATIVITY) + result = compiler.compile() + self.assertTrue("expectation" in result.method_history) + + @patch.object(SV_SIM, "measure_qubit_expectation_values") + def test_given_entanglement_when_find_highest_entanglement_pair_then_evals_not_evaluated( + self, mock_get_evals + ): + compiler = AdaptCompiler(QuantumCircuit(2)) + compiler._find_best_entanglement_qubit_pair([0.5]) + mock_get_evals.assert_not_called() + + @patch.object(SV_SIM, "measure_qubit_expectation_values") + def test_given_entanglement_when_find_appropriate_pair_then_evals_not_evaluated( + self, mock_get_evals + ): + qc = QuantumCircuit(2) + qc.h(0) + qc.cx(0, 1) + compiler = AdaptCompiler(qc) + compiler._find_appropriate_qubit_pair() + mock_get_evals.assert_not_called() + + def test_given_compiling_with_isql_when_add_layer_then_correct_indices_modified( + self, + ): + qc = co.create_random_initial_state_circuit(3, seed=0) + num_gates_u = len(qc.data) + config = AdaptConfig(rotosolve_frequency=1e5) + compiler = AdaptCompiler( + qc, + initial_single_qubit_layer=True, + adapt_config=config, + ) + compiler._add_layer(0) + full_circuit = compiler.full_circuit.copy() + layer_1_data = full_circuit[num_gates_u:] + for gate in layer_1_data: + self.assertTrue( + gate[0].params[0] != 0, "Added layer should have modified angles" + ) + + compiler._add_layer(1) + full_circuit = compiler.full_circuit.copy() + layer_1_and_2_data = full_circuit[num_gates_u:] + self.assertEqual( + layer_1_data, + layer_1_and_2_data[: len(layer_1_data)], + "Adding next layer and using Rotoselect should not modify previous layer", + ) + + layer_2_data = layer_1_and_2_data[len(layer_1_data) :] + for gate in layer_2_data: + if gate[0].name != "cx": + self.assertTrue( + gate[0].params[0] != 0, "Added layer should have modified angles" + ) + + def test_given_random_circuit_and_starting_circuit_True_when_count_gates_in_solution_then_correct( + self, + ): + qc = co.create_random_circuit(3) + starting_circuit = co.create_random_initial_state_circuit(3) + compiler = AdaptCompiler(qc, starting_circuit=starting_circuit) + result = compiler.compile() + + num_1q_gates = 0 + num_2q_gates = 0 + for instr in result.circuit.data: + if instr.operation.name == "cx": + num_2q_gates += 1 + elif instr.operation.name == "rx" or "ry" or "rz": + num_1q_gates += 1 + + self.assertEqual( + (num_1q_gates, num_2q_gates), (result.num_1q_gates, result.num_2q_gates) + ) + + def test_given_wrong_reuse_prio_mode_when_compile_then_error(self): + qc = co.create_random_initial_state_circuit(4) + config = AdaptConfig(reuse_priority_mode="foo") + compiler = AdaptCompiler(qc, adapt_config=config) + with self.assertRaises(ValueError): + compiler.compile() + + def test_when_add_layer_then_previous_pair_reuse_priority_minus_1(self): + qc = co.create_random_initial_state_circuit(4) + config = AdaptConfig(rotosolve_frequency=1e5) + compiler = AdaptCompiler( + qc, + adapt_config=config, + ) + compiler._add_layer(0) + + pair_acted_on = compiler.qubit_pair_history[0] + priority = compiler._get_qubit_reuse_priority(pair_acted_on, k=0) + + self.assertEqual(priority, -1) + + def test_given_exponent_equal_to_zero_when_find_reuse_priorities_then_correct(self): + qc = co.create_random_initial_state_circuit(4) + config = AdaptConfig(rotosolve_frequency=1e5) + compiler = AdaptCompiler( + qc, + adapt_config=config, + ) + compiler._add_layer(0) + + pair_acted_on = compiler.qubit_pair_history[0] + priorities = compiler._get_all_qubit_pair_reuse_priorities(k=0) + + for pair in compiler.coupling_map: + if pair != pair_acted_on: + self.assertEqual(priorities[compiler.coupling_map.index(pair)], 1) + + def test_given_exponent_equal_to_one_when_find_qubit_reuse_priorities_then_correct( + self, + ): + qc = co.create_random_initial_state_circuit(4) + config = AdaptConfig( + rotosolve_frequency=1e5, reuse_exponent=1, reuse_priority_mode="qubit" + ) + compiler = AdaptCompiler( + qc, + adapt_config=config, + ) + compiler._add_layer(0) + + pair_acted_on = compiler.qubit_pair_history[0] + priorities = compiler._get_all_qubit_pair_reuse_priorities(k=1) + + for pair in compiler.coupling_map: + if pair != pair_acted_on: + if pair[0] in pair_acted_on or pair[1] in pair_acted_on: + self.assertEqual(priorities[compiler.coupling_map.index(pair)], 0.5) + else: + self.assertEqual(priorities[compiler.coupling_map.index(pair)], 1) + + def test_given_random_exponents_when_add_layer_then_same_qubit_pair_never_acted_on_twice_in_a_row( + self, + ): + qc = co.create_random_initial_state_circuit(4) + config = AdaptConfig( + rotosolve_frequency=1e5, + reuse_exponent=np.random.rand() * 2, + ) + compiler = AdaptCompiler( + qc, + adapt_config=config, + ) + compiler._add_layer(0) + for i in range(10): + compiler._add_layer(i + 1) + self.assertTrue( + compiler.qubit_pair_history[-1] != compiler.qubit_pair_history[-2], + "Same pair should not be acted on twice", + ) + + def test_given_circuit_when_manually_find_correct_pair_to_act_on_then_pair_acted_on_by_add_layer( + self, + ): + qc = co.create_random_initial_state_circuit(4) + config = AdaptConfig(rotosolve_frequency=1e5, reuse_exponent=1) + compiler = AdaptCompiler( + qc, + adapt_config=config, + ) + compiler._add_layer(0) + + # Manually find pair which should be acted on when add_layer() is called + reuse_priorities = compiler._get_all_qubit_pair_reuse_priorities(k=1) + entanglements = compiler._get_all_qubit_pair_entanglement_measures() + priorities = [ + reuse_priorities[i] * entanglements[i] for i in range(len(reuse_priorities)) + ] + correct_pair = compiler.coupling_map[priorities.index(max(priorities))] + + compiler._add_layer(1) + + self.assertTrue(compiler.qubit_pair_history[-1] == correct_pair) + + def test_given_random_rotosolve_frequency_and_max_layers_to_modify_values_when_compile_mps_then_works( + self, + ): + n = 3 + starting_circuit = QuantumCircuit(n) + starting_circuit.x(range(0, n, 2)) + for isql in [True, False]: + for sc in [starting_circuit, None, "tenpy_product_state"]: + qc = co.create_random_initial_state_circuit(n) + rotosolve_frequency = np.random.randint(1, 101) + max_layers_to_modify = np.random.randint(1, 101) + config = AdaptConfig( + rotosolve_frequency=rotosolve_frequency, + max_layers_to_modify=max_layers_to_modify, + cost_improvement_num_layers=100, + ) + compiler = AdaptCompiler( + qc, + backend=MPS_SIM, + adapt_config=config, + starting_circuit=sc, + initial_single_qubit_layer=isql, + ) + result = compiler.compile() + overlap = co.calculate_overlap_between_circuits(qc, result.circuit) + + self.assertGreater(overlap, 1 - DEFAULT_SUFFICIENT_COST) + + def test_given_mps_backend_when_add_layer_then_num_gates_not_in_mps_is_as_expected( + self, + ): + # Test both cases of using/not using an initial ansatz + initial_ansatz_circ = create_initial_ansatz() + + qc = co.create_random_initial_state_circuit(4) + config = AdaptConfig(rotosolve_frequency=4, max_layers_to_modify=3) + # Rotosolve happens on layers 4, 8, 12... + # Add layer 0: absorb layer -> 0 gates left in ansatz + # Add layer 1: absorb layer -> 0 gates + # Add layer 2: don't absorb layer -> 5 gates + # Add layer 3: don't absorb layer -> 10 gates + # Add layer 4: absorb layers 2, 3, 4 -> 0 gates + # Etc. + expected_num_gates = [0, 0, 5, 10, 0, 0, 5, 10, 0, 0, 5, 10, 0] + for initial_ansatz in [initial_ansatz_circ, None]: + compiler = AdaptCompiler(qc, backend=MPS_SIM, adapt_config=config) + actual_num_gates = [] + if initial_ansatz is not None: + # initial_ansatz should be absorbed into MPS and added to ref_circuit_as_gates + compiler._add_initial_ansatz( + initial_ansatz, optimise_initial_ansatz=True + ) + self.assertEqual(len(compiler.full_circuit), 1) + self.assertEqual(len(compiler.ref_circuit_as_gates), 12) + for i in range(13): + compiler._add_layer(i) + actual_num_gates.append(len(compiler.full_circuit.data) - 1) + + np.testing.assert_equal(actual_num_gates, expected_num_gates) + + def test_given_max_layers_larger_than_freq_when_add_layer_then_num_gates_not_in_mps_as_expected( + self, + ): + qc = co.create_random_initial_state_circuit(4) + config = AdaptConfig(rotosolve_frequency=4, max_layers_to_modify=5) + compiler = AdaptCompiler(qc, backend=MPS_SIM, adapt_config=config) + # layer counter 0 1 2 3 4 5 6 7 8 9 10 11 12 + expected_num_gates = [5, 10, 15, 20, 5, 10, 15, 20, 5, 10, 15, 20, 5] + actual_num_gates = [] + for i in range(13): + compiler._add_layer(i) + actual_num_gates.append(len(compiler.full_circuit.data) - 1) + + np.testing.assert_equal(actual_num_gates, expected_num_gates) + + def test_given_optimise_local_cost_when_compile_then_global_cost_converged(self): + qc = co.create_random_initial_state_circuit(3) + compiler = AdaptCompiler(qc, optimise_local_cost=True) + result = compiler.compile() + circuit = result.circuit + overlap = co.calculate_overlap_between_circuits(qc, circuit) + self.assertGreater(overlap, 1 - DEFAULT_SUFFICIENT_COST) + + def test_given_optimise_local_cost_when_compile_then_global_and_local_cost_histories_correct( + self, + ): + # Tests that: + # a) global_cost_history has one extra element (final cost) + # b) every global cost is greater than its corresponding local cost + qc = co.create_random_initial_state_circuit(3) + compiler = AdaptCompiler(qc, optimise_local_cost=True) + result = compiler.compile() + self.assertEqual( + len(result.global_cost_history), len(result.local_cost_history) + 1 + ) + for global_cost, local_cost in zip( + result.global_cost_history[:-1], result.local_cost_history + ): + self.assertGreaterEqual(np.round(global_cost, 15), np.round(local_cost, 15)) + + def test_given_initial_ansatz_and_starting_circuit_and_isql_and_layer_caching_then_solution_has_correct_gates( + self, + ): + qc = co.create_random_initial_state_circuit(4) + + starting_circuit = QuantumCircuit(4) + starting_circuit.x([0, 1, 2, 3]) + + initial_ansatz = create_initial_ansatz() + + config = AdaptConfig(rotosolve_frequency=4, max_layers_to_modify=2) + compiler = AdaptCompiler( + qc, + backend=MPS_SIM, + adapt_config=config, + starting_circuit=starting_circuit, + initial_single_qubit_layer=True, + ) + + compiler._add_initial_ansatz( + initial_ansatz=initial_ansatz, optimise_initial_ansatz=True + ) + [compiler._add_layer(i) for i in range(5)] + + # Delete set_matrix_product_state instruction + del compiler.ref_circuit_as_gates.data[0] + full_circuit = compiler.ref_circuit_as_gates.copy() + + # First 11 gates should be the same type as in initial_ansatz inverse + initial_ansatz_part = [gate for gate in full_circuit[:11]] + self.assertEqual( + [gate.operation.name for gate in initial_ansatz_part], + ["rx", "rx", "rx", "rx", "cx", "cx", "cx", "ry", "ry", "ry", "ry"], + ) + + # Next 4 gates should be initial single qubit layer + isql_part = [gate for gate in full_circuit[11:15]] + self.assertTrue( + all(gate.operation.name in ["rx", "ry", "rz"] for gate in isql_part) + ) + + # Everything in between should be thinly-dressed CNOTs + middle_part = [gate for gate in full_circuit[15:-4]] + for i, gate in enumerate(middle_part): + if i % 5 == 2: + self.assertEqual(gate.operation.name, "cx") + else: + self.assertTrue(gate.operation.name in ["rx", "ry", "rz"]) + self.assertEqual(len(middle_part) % 5, 0) + + # Final 4 gates should be starting_circuit inverse + starting_circuit_part = [gate for gate in full_circuit[-4:]] + self.assertTrue( + all(gate.operation.name == "rx" for gate in starting_circuit_part) + ) + self.assertTrue( + all(gate.operation.params[0] == np.pi for gate in starting_circuit_part) + ) + + # Make sure the circuit has been partitioned correctly + reconstruct_circuit = ( + initial_ansatz_part + isql_part + middle_part + starting_circuit_part + ) + self.assertEqual(compiler.ref_circuit_as_gates.data, reconstruct_circuit) + + def test_given_optimise_initial_ansatz_false_then_initial_ansatz_gates_unchanged( + self, + ): + qc = co.create_random_initial_state_circuit(2) + + initial_ansatz = QuantumCircuit(2) + initial_ansatz.cz(0, 1) + initial_ansatz.ry(2.67, 0) + initial_ansatz.rx(0.53, 1) + + compiler = AdaptCompiler(qc) + result = compiler.compile( + initial_ansatz=initial_ansatz, optimise_initial_ansatz=False + ) + + self.assertEqual(result.circuit.data[-3:], initial_ansatz.data) + + def test_given_initial_ansatz_when_add_layer_then_initial_ansatz_unchanged(self): + qc = co.create_random_initial_state_circuit(4) + target_gates = len(qc) + initial_ansatz = create_initial_ansatz() + + config = AdaptConfig(rotosolve_frequency=1, max_layers_to_modify=10) + compiler = AdaptCompiler(qc, adapt_config=config) + + compiler._add_initial_ansatz(initial_ansatz, optimise_initial_ansatz=True) + ia_gates_before = [ + gate for gate in compiler.full_circuit[target_gates : (target_gates + 11)] + ] + + # Rotosolve will occur during layer 1, not layer 0 + compiler._add_layer(0) + compiler._add_layer(1) + ia_gates_after = [ + gate for gate in compiler.full_circuit[target_gates : (target_gates + 11)] + ] + + self.assertEqual(ia_gates_before, ia_gates_after) + + def test_cnot_depth_in_adapt_result_correct(self): + qc = co.create_random_initial_state_circuit(4, seed=1) + compiler = AdaptCompiler(qc) + result = compiler.compile() + circuit = result.circuit + self.assertEqual(multi_qubit_gate_depth(circuit), result.cnot_depth_history[-1]) + + def test_recompiling_from_tenpy_mps_works(self): + n = 3 + num_trotter_steps = 5 + dt = 0.4 + # Target from tenpy + # NOTE: our Hamiltonian with delta and field is equivalent to tenpy's XXZChain model with + # Jxx = -1, Jz = -delta, hz = -field. + model = XXZChain( + { + "L": n, + "Jxx": -1.0, + "Jz": -1.0, + "hz": 0.0, + "bc_MPS": "finite", + } + ) + neel_state = ["down", "up", "down"] + psi = MPS.from_product_state( + model.lat.mps_sites(), neel_state, bc=model.lat.bc_MPS + ) + + tebd_params = { + "N_steps": num_trotter_steps, + "dt": dt, + "order": 2, + "trunc_params": {"chi_max": 100, "svd_min": 1.0e-12}, + } + + eng = tebd.TEBDEngine(psi, model, tebd_params) + eng.run() + target_mps = tenpy_to_qiskit_mps(psi) + + # Compile + starting_circuit = QuantumCircuit(n) + starting_circuit.x(range(0, n, 2)) + + compiler = AdaptCompiler( + target_mps, backend=MPS_SIM, starting_circuit=starting_circuit + ) + result = compiler.compile() + + # Target circuit created independently for comparison + qc = QuantumCircuit(n) + qc.x(range(0, n, 2)) + + trotter_circuit( + qc, + dt=dt, + delta=1.0, + field=0.0, + num_trotter_steps=num_trotter_steps, + second_order=True, + ) + + overlap = co.calculate_overlap_between_circuits(result.circuit, qc) + + self.assertGreater(overlap, 1 - DEFAULT_SUFFICIENT_COST) + + def test_given_general_gradient_method_when_compile_then_works(self): + qc = co.create_random_initial_state_circuit(3) + + config = AdaptConfig(method="general_gradient") + compiler = AdaptCompiler( + qc, + backend=MPS_SIM, + adapt_config=config, + custom_layer_2q_gate=ans.identity_resolvable(), + ) + result = compiler.compile() + + overlap = co.calculate_overlap_between_circuits(qc, result.circuit) + self.assertGreater(overlap, 1 - DEFAULT_SUFFICIENT_COST) + + def test_given_general_gradient_when_compile_with_reuse_exponent_then_works(self): + qc = co.create_random_initial_state_circuit(3) + + config = AdaptConfig(method="general_gradient", reuse_exponent=1) + compiler = AdaptCompiler( + qc, + backend=MPS_SIM, + adapt_config=config, + custom_layer_2q_gate=ans.identity_resolvable(), + ) + result = compiler.compile() + + overlap = co.calculate_overlap_between_circuits(qc, result.circuit) + self.assertGreater(overlap, 1 - DEFAULT_SUFFICIENT_COST) + + def test_given_soften_global_cost_when_compile_then_works(self): + qc = co.create_random_initial_state_circuit(3) + + compiler = AdaptCompiler(qc, backend=MPS_SIM, soften_global_cost=True) + result = compiler.compile() + self.assertLessEqual(compiler.evaluate_cost(), DEFAULT_SUFFICIENT_COST) + + @patch( + "adaptaqc.backends.aer_mps_backend.AerMPSBackend.evaluate_hamming_weight_one_overlaps" + ) + def test_given_soften_global_cost_true_or_false_when_evaluate_cost_then_appropriate_logic_executed( + self, mock + ): + # This test checks that when evaluate_cost is called, the Hamming-weight-one overlaps are + # calculated if soften_global_cost=True and not calculated if soften_global_cost=False. + compiler = AdaptCompiler( + QuantumCircuit(3), + backend=MPS_SIM, + soften_global_cost=False, + ) + compiler.global_cost_history = [] + compiler.evaluate_cost() + mock.assert_not_called() + + compiler = AdaptCompiler( + QuantumCircuit(3), + backend=MPS_SIM, + soften_global_cost=True, + ) + compiler.global_cost_history = [] + compiler.evaluate_cost() + mock.assert_called() + + def test_given_soften_global_cost_and_aer_sv_backend_then_error(self): + qc = co.create_random_initial_state_circuit(3) + compiler = AdaptCompiler( + qc, + backend=SV_SIM, + soften_global_cost=True, + ) + with self.assertRaises(NotImplementedError): + compiler.compile() + + def test_given_soften_global_cost_and_qiskit_sampling_backend_then_error(self): + qc = co.create_random_initial_state_circuit(3) + compiler = AdaptCompiler( + qc, + backend=QASM_SIM, + soften_global_cost=True, + ) + with self.assertRaises(NotImplementedError): + compiler.compile() + + def test_given_tenpy_starting_circuit_when_compile_then_works(self): + qc = co.create_random_initial_state_circuit(3) + compiler = AdaptCompiler(qc, starting_circuit="tenpy_product_state") + result = compiler.compile() + + overlap = co.calculate_overlap_between_circuits(result.circuit, qc) + self.assertGreater(overlap, 1 - DEFAULT_SUFFICIENT_COST) + + def test_given_tenpy_starting_circuit_then_solution_starts_with_rzryrz_on_each_qubit( + self, + ): + qc = co.create_random_initial_state_circuit(3) + compiler = AdaptCompiler(qc, starting_circuit="tenpy_product_state") + result = compiler.compile() + + qubit_0_gates = [] + qubit_1_gates = [] + qubit_2_gates = [] + + for instruction in result.circuit.data: + if min([len(qubit_0_gates), len(qubit_1_gates), len(qubit_2_gates)]) >= 3: + break + elif (instruction.qubits[0] == result.circuit.qubits[0]) and ( + len(qubit_0_gates) < 3 + ): + qubit_0_gates.append(instruction.operation.name) + elif (instruction.qubits[0] == result.circuit.qubits[1]) and ( + len(qubit_1_gates) < 3 + ): + qubit_1_gates.append(instruction.operation.name) + elif (instruction.qubits[0] == result.circuit.qubits[2]) and ( + len(qubit_2_gates) < 3 + ): + qubit_2_gates.append(instruction.operation.name) + + self.assertEqual(qubit_0_gates, ["rz", "ry", "rz"]) + self.assertEqual(qubit_1_gates, ["rz", "ry", "rz"]) + self.assertEqual(qubit_2_gates, ["rz", "ry", "rz"]) + + def test_given_tenpy_starting_circuit_then_better_starting_cost(self): + qc = co.create_random_initial_state_circuit(5) + compiler_1 = AdaptCompiler(qc) + compiler_2 = AdaptCompiler(qc, starting_circuit="tenpy_product_state") + + cost_1 = compiler_1.evaluate_cost() + cost_2 = compiler_2.evaluate_cost() + + self.assertGreater(cost_1, cost_2) + + def test_given_advanced_transpilation_option_passed_then_compiled_circuits_equivalent( + self, + ): + qc = co.create_random_initial_state_circuit(4) + compiler = AdaptCompiler( + qc, + use_advanced_transpilation=True, + ) + + result = compiler.compile() + circuit = result.circuit + + overlap = co.calculate_overlap_between_circuits(qc, circuit) + self.assertGreater(overlap, 1 - DEFAULT_SUFFICIENT_COST) + + def test_given_advanced_transpilation_option_passed_then_reference_circuit_updated_correctly( + self, + ): + qc = co.create_random_initial_state_circuit(3) + compiler = AdaptCompiler( + qc, + backend=MPS_SIM, + use_advanced_transpilation=True, + ) + for i in range(3): + compiler._add_layer(i) + full_circuit = compiler.full_circuit.copy() + self.assertEqual(compiler.ref_circuit_as_gates.data, full_circuit.data) + + +class TestAdaptCheckpointing(TestCase): + def test_given_checkpoint_every_1_when_compile_then_n_layer_number_of_checkpoints( + self, + ): + qc = co.create_random_initial_state_circuit(3) + compiler = AdaptCompiler(qc) + with tempfile.TemporaryDirectory() as d: + result = compiler.compile(checkpoint_every=1, checkpoint_dir=d) + self.assertEqual(len(os.listdir(d)), len(result.qubit_pair_history)) + + def test_given_delete_prev_chkpt_when_compile_then_1_checkpoint(self): + qc = co.create_random_initial_state_circuit(3) + compiler = AdaptCompiler(qc) + with tempfile.TemporaryDirectory() as d: + compiler.compile( + checkpoint_every=1, checkpoint_dir=d, delete_prev_chkpt=True + ) + self.assertEqual(len(os.listdir(d)), 1) + + def test_given_delete_prev_chkpt_when_save_then_load_then_load_checkpoint_deleted( + self, + ): + qc = co.create_random_initial_state_circuit(3) + compiler = AdaptCompiler(qc, adapt_config=AdaptConfig(max_layers=2)) + with tempfile.TemporaryDirectory() as d: + compiler.compile( + checkpoint_every=1, checkpoint_dir=d, delete_prev_chkpt=True + ) + with open(os.path.join(d, "1.pkl"), "rb") as myfile: + loaded_compiler = pickle.load(myfile) + loaded_compiler.compile( + checkpoint_every=1, checkpoint_dir=d, delete_prev_chkpt=True + ) + self.assertEqual(len(os.listdir(d)), 1) + + def test_given_checkpoint_every_large_when_compile_then_2_checkpoints(self): + qc = co.create_random_initial_state_circuit(3) + compiler = AdaptCompiler(qc) + with tempfile.TemporaryDirectory() as d: + compiler.compile(checkpoint_every=100, checkpoint_dir=d) + self.assertEqual(len(os.listdir(d)), 2) + + def test_given_checkpoint_every_0_when_compile_then_no_dir_created(self): + qc = co.create_random_initial_state_circuit(3) + compiler = AdaptCompiler(qc) + with tempfile.TemporaryDirectory() as d: + shutil.rmtree(d) + compiler.compile(checkpoint_every=0, checkpoint_dir=d) + self.assertFalse(os.path.isdir(d)) + + def test_given_checkpointing_when_compile_then_dir_created(self): + qc = co.create_random_initial_state_circuit(3) + compiler = AdaptCompiler(qc) + with tempfile.TemporaryDirectory() as d: + shutil.rmtree(d) + compiler.compile(checkpoint_every=100, checkpoint_dir=d) + self.assertTrue(os.path.isdir(d)) + + def test_given_save_and_resume_from_different_points_then_non_time_results_equal( + self, + ): + qc = co.create_random_initial_state_circuit(3) + compiler = AdaptCompiler(qc) + with tempfile.TemporaryDirectory() as d: + result = compiler.compile(checkpoint_every=1, checkpoint_dir=d) + for c in ["0", "1"]: + with open(os.path.join(d, c + ".pkl"), "rb") as myfile: + loaded_compiler = pickle.load(myfile) + result_1 = loaded_compiler.compile() + for key in result.__dict__.keys(): + if key != "time_taken": + self.assertEqual(result.__dict__[key], result_1.__dict__[key]) + + def test_given_save_and_resume_from_any_point_then_time_taken_within_100ms(self): + qc = co.create_random_initial_state_circuit(3, seed=3) + compiler = AdaptCompiler(qc) + with tempfile.TemporaryDirectory() as d: + result = compiler.compile(checkpoint_every=1, checkpoint_dir=d) + int_cn = [int(cn[:-4]) for cn in os.listdir(d)] + for c in [str(i) for i in range(max(int_cn) + 1)]: + with open(os.path.join(d, c + ".pkl"), "rb") as myfile: + loaded_compiler = pickle.load(myfile) + result_1 = loaded_compiler.compile() + self.assertAlmostEqual( + result.time_taken, result_1.time_taken, delta=0.1 + ) + self.assertLess(result_1.time_taken, 100) + + def test_given_save_and_resume_and_save_and_resume_then_overwrites(self): + qc = co.create_random_initial_state_circuit(3) + compiler = AdaptCompiler(qc) + with tempfile.TemporaryDirectory() as d: + compiler.compile(checkpoint_every=1, checkpoint_dir=d) + with open(os.path.join(d, "0.pkl"), "rb") as myfile: + loaded_compiler = pickle.load(myfile) + loaded_compiler.compile(checkpoint_every=1, checkpoint_dir=d) + with open(os.path.join(d, "1.pkl"), "rb") as myfile: + loaded_compiler = pickle.load(myfile) + result = loaded_compiler.compile() + self.assertEqual(len(os.listdir(d)), len(result.qubit_pair_history)) + + def test_given_resume_and_freeze_layers_when_compile_then_works(self): + qc = co.create_random_initial_state_circuit(3) + for backend in [SV_SIM, MPS_SIM]: + compiler = AdaptCompiler(qc, backend=backend) + with tempfile.TemporaryDirectory() as d: + compiler.compile(checkpoint_every=1, checkpoint_dir=d) + with open(os.path.join(d, "2.pkl"), "rb") as myfile: + loaded_compiler = pickle.load(myfile) + result = loaded_compiler.compile(freeze_prev_layers=True) + overlap = co.calculate_overlap_between_circuits(result.circuit, qc) + self.assertGreater(overlap, 1 - DEFAULT_SUFFICIENT_COST) + + def test_given_resume_and_freeze_layers_multiple_times_when_compile_then_works( + self, + ): + qc = co.create_random_initial_state_circuit(3) + sc = QuantumCircuit(3) + sc.h([0, 2]) + for backend in [SV_SIM, MPS_SIM]: + compiler = AdaptCompiler(qc, backend=backend, starting_circuit=sc) + with tempfile.TemporaryDirectory() as d: + compiler.compile(checkpoint_every=1, checkpoint_dir=d) + with open(os.path.join(d, "0.pkl"), "rb") as myfile: + # Load compiler after layer 0, freeze layer 0, compile + loaded_compiler_0 = pickle.load(myfile) + + with tempfile.TemporaryDirectory() as d: + loaded_compiler_0.compile( + checkpoint_every=1, checkpoint_dir=d, freeze_prev_layers=True + ) + with open(os.path.join(d, "1.pkl"), "rb") as myfile: + # Load compiler after layer 1, freeze layers 0, 1, compile + loaded_compiler_1 = pickle.load(myfile) + + with tempfile.TemporaryDirectory() as d: + loaded_compiler_1.compile( + checkpoint_every=1, checkpoint_dir=d, freeze_prev_layers=True + ) + with open(os.path.join(d, "2.pkl"), "rb") as myfile: + # Load compiler after layer 2, freeze layers 0, 1, 2, compile + loaded_compiler_2 = pickle.load(myfile) + + result = loaded_compiler_2.compile(freeze_prev_layers=True) + overlap = co.calculate_overlap_between_circuits(result.circuit, qc) + self.assertGreater(overlap, 1 - DEFAULT_SUFFICIENT_COST) + + def test_given_freeze_prev_layers_then_parameters_unchanged_sv(self): + # This tests that given freeze_prev_layers=False(True), then the layers added before the + # checkpoint are(are not) changed during the resumed recompilation. + qc = co.create_random_initial_state_circuit(3, seed=0) + target_length = len(qc) + # We will load the compiler after two layers have been added, so if we freeze those layers, + # these gates should be in the range: + frozen_gate_range = (target_length, target_length + 10) + compiler = AdaptCompiler(qc, backend=SV_SIM) + with tempfile.TemporaryDirectory() as d: + compiler.compile(checkpoint_every=1, checkpoint_dir=d) + with open(os.path.join(d, "1.pkl"), "rb") as myfile: + compiler_freeze = pickle.load(myfile) + compiler_no_freeze = copy.deepcopy(compiler_freeze) + + layers_added_before_checkpoint = co.extract_inner_circuit( + compiler_freeze.full_circuit, frozen_gate_range + ) + compiler_freeze.compile(freeze_prev_layers=True) + compiler_no_freeze.compile(freeze_prev_layers=False) + + layers_after_recompiling_with_freezing = co.extract_inner_circuit( + compiler_freeze.full_circuit, frozen_gate_range + ) + layers_after_recompiling_without_freezing = co.extract_inner_circuit( + compiler_no_freeze.full_circuit, frozen_gate_range + ) + + self.assertEqual( + layers_added_before_checkpoint, layers_after_recompiling_with_freezing + ) + self.assertNotEqual( + layers_added_before_checkpoint, + layers_after_recompiling_without_freezing, + ) + + def test_given_freeze_prev_layers_then_parameters_unchanged_mps(self): + # This test is the same above, but for the aer mps backend. + qc = co.create_random_initial_state_circuit(3) + # For mps backend, the target is a set_matrix_product_state in compiler.ref_circuit_as_gates + frozen_gate_range = (1, 11) + compiler = AdaptCompiler(qc, backend=MPS_SIM) + with tempfile.TemporaryDirectory() as d: + compiler.compile(checkpoint_every=1, checkpoint_dir=d) + with open(os.path.join(d, "1.pkl"), "rb") as myfile: + compiler_freeze = pickle.load(myfile) + compiler_no_freeze = copy.deepcopy(compiler_freeze) + + layers_added_before_checkpoint = co.extract_inner_circuit( + compiler_freeze.ref_circuit_as_gates, frozen_gate_range + ) + compiler_freeze.compile(freeze_prev_layers=True) + compiler_no_freeze.compile(freeze_prev_layers=False) + + layers_after_recompiling_with_freezing = co.extract_inner_circuit( + compiler_freeze.ref_circuit_as_gates, frozen_gate_range + ) + layers_after_recompiling_without_freezing = co.extract_inner_circuit( + compiler_no_freeze.ref_circuit_as_gates, frozen_gate_range + ) + + self.assertEqual( + layers_added_before_checkpoint, layers_after_recompiling_with_freezing + ) + self.assertNotEqual( + layers_added_before_checkpoint, + layers_after_recompiling_without_freezing, + ) + + def test_given_freeze_prev_layers_then_lhs_gate_count_different_from_orig_during_recompiling( + self, + ): + # When doing rotosolve, AdaptCompiler._calculate_multi_layer_optimisation_indices is called + # with "ansatz_start_index" as argument. This is equal to variational_circuit_range()[0], + # which is equal to lhs_gate_count. We can check that this is different from + # original_lhs_gate_count + qc = co.create_random_initial_state_circuit(3) + compiler = AdaptCompiler(qc) + with tempfile.TemporaryDirectory() as d: + compiler.compile(checkpoint_every=1, checkpoint_dir=d) + with open(os.path.join(d, "1.pkl"), "rb") as myfile: + loaded_compiler = pickle.load(myfile) + + # Since we loaded the compiler after two layers had been added, we expect the + # lhs_gate_count used during recompilation to be: old_lhs + 10 + expected_input = loaded_compiler.original_lhs_gate_count + 10 + + with patch.object( + loaded_compiler, "_calculate_multi_layer_optimisation_indices" + ) as mock: + # Dummy return value + mock.return_value = loaded_compiler.variational_circuit_range() + # Compile and assert that all calls to the function use the right input + loaded_compiler.compile(freeze_prev_layers=True) + for call in mock.call_args_list: + self.assertEqual(call.args[0], expected_input) + + def test_given_save_and_resume_then_rotosolve_fraction_is_not_overwritten(self): + qc = co.create_random_initial_state_circuit(3) + compiler = AdaptCompiler(qc, rotosolve_fraction=0.5) + + rotosolve_fractions = [] + # Before recompilation + rotosolve_fractions.append(compiler.minimizer.rotosolve_fraction) + with tempfile.TemporaryDirectory() as d: + compiler.compile(checkpoint_every=1, checkpoint_dir=d) + # After recompilation + rotosolve_fractions.append(compiler.minimizer.rotosolve_fraction) + with open(os.path.join(d, "1.pkl"), "rb") as myfile: + loaded_compiler = pickle.load(myfile) + + # After loading, before resuming recompilation + rotosolve_fractions.append(loaded_compiler.minimizer.rotosolve_fraction) + loaded_compiler.compile(checkpoint_every=1, checkpoint_dir=d) + # After loading, after resuming recompilation + rotosolve_fractions.append(loaded_compiler.minimizer.rotosolve_fraction) + + self.assertEqual(rotosolve_fractions, [0.5, 0.5, 0.5, 0.5]) + + +class TestAdaptRandomRotosolve(TestCase): + def test_given_different_rotosolve_fractions_when_compile_then_works(self): + qc = co.create_random_initial_state_circuit(3) + + for rotosolve_fraction in [0.2, 0.5, 0.8]: + compiler = AdaptCompiler( + qc, backend=MPS_SIM, rotosolve_fraction=rotosolve_fraction + ) + result = compiler.compile() + + overlap = co.calculate_overlap_between_circuits(qc, result.circuit) + + self.assertGreater(overlap, 1 - DEFAULT_SUFFICIENT_COST) + + def test_given_rotosolve_fraction_then_results_reproducible(self): + qc = co.create_random_initial_state_circuit(3) + + compiler_1 = AdaptCompiler(qc, backend=MPS_SIM, rotosolve_fraction=0.5) + compiler_2 = AdaptCompiler(qc, backend=MPS_SIM, rotosolve_fraction=0.5) + + random.seed(1) + result_1 = compiler_1.compile() + + random.seed(1) + result_2 = compiler_2.compile() + + self.assertEqual(result_1.global_cost_history, result_2.global_cost_history) + self.assertEqual(result_1.circuit, result_2.circuit) + + def test_given_invalid_or_valid_rotosolve_fraction_then_error_or_no_error(self): + qc = co.create_random_initial_state_circuit(3) + + # Should error + with self.assertRaises(ValueError): + compiler = AdaptCompiler(qc, backend=MPS_SIM, rotosolve_fraction=0) + + with self.assertRaises(ValueError): + compiler = AdaptCompiler( + qc, backend=MPS_SIM, rotosolve_fraction=1.000000001 + ) + + # Shouldn't error + compiler = AdaptCompiler(qc, backend=MPS_SIM, rotosolve_fraction=1) + compiler = AdaptCompiler(qc, backend=MPS_SIM, rotosolve_fraction=0.000000001) + + +try: + from itensornetworks_qiskit.utils import qiskit_circ_to_it_circ + from juliacall import Main as jl, JuliaError + from adaptaqc.backends.julia_default_backends import ITENSOR_SIM + + jl.seval("using ITensorNetworksQiskit") + jl.seval("using ITensors: siteinds") + module_failed = False +except Exception: + module_failed = True + + +class TestITensor(TestCase): + def setUp(self): + if module_failed: + self.skipTest("Skipping as ITensor backend not set up") + + def test_given_itensor_backend_when_compile_with_basic_then_works(self): + qc = co.create_random_initial_state_circuit(3) + qc = transpile(qc, basis_gates=["cx", "rx", "ry", "rz"]) + config = AdaptConfig(method="basic") + compiler = AdaptCompiler(qc, backend=ITENSOR_SIM, adapt_config=config) + result = compiler.compile() + overlap = co.calculate_overlap_between_circuits(qc, result.circuit) + self.assertGreater(overlap, 1 - DEFAULT_SUFFICIENT_COST) + + def test_given_itensor_backend_when_compile_with_adapt_then_error(self): + with self.assertRaises(NotImplementedError): + AdaptCompiler(QuantumCircuit(1), backend=ITENSOR_SIM).compile() + + def test_given_itensor_backend_when_compile_with_expectation_then_error(self): + with self.assertRaises(NotImplementedError): + config = AdaptConfig(method="expectation") + AdaptCompiler( + QuantumCircuit(1), backend=ITENSOR_SIM, adapt_config=config + ).compile() + + def test_given_itensor_backend_then_target_cached(self): + qc = co.create_random_initial_state_circuit(3) + qc = transpile(qc, basis_gates=["cx", "rx", "ry", "rz"]) + compiler = AdaptCompiler(qc, backend=ITENSOR_SIM) + + s = compiler.itensor_sites + cached_target = compiler.itensor_target + gates = qiskit_circ_to_it_circ(qc) + actual_target = jl.mps_from_circuit_itensors(3, gates, 10, s) + + overlap = jl.overlap_itensors(cached_target, actual_target) + self.assertAlmostEqual(overlap, 1) + + def test_given_itensor_backend_then_cache_not_modified(self): + qc = co.create_random_initial_state_circuit(3) + qc = transpile(qc, basis_gates=["cx", "rx", "ry", "rz"]) + config = AdaptConfig(method="basic") + compiler = AdaptCompiler(qc, backend=ITENSOR_SIM, adapt_config=config) + cached_target = compiler.itensor_target + compiler._add_layer(0) + compiler._add_layer(1) + compiler._add_layer(2) + + cached_target_after_layers_added = compiler.itensor_target + + overlap = jl.overlap_itensors(cached_target, cached_target_after_layers_added) + self.assertAlmostEqual(overlap, 1) + + def test_given_soften_global_cost_and_itensor_backend_then_error(self): + qc = co.create_random_initial_state_circuit(3) + compiler = AdaptCompiler( + qc, + backend=ITENSOR_SIM, + soften_global_cost=True, + ) + with self.assertRaises(NotImplementedError): + compiler.compile() + + +class TestBrickwall(TestCase): + def test_given_brickwall_pair_selection_method_when_compile_then_works(self): + qc = co.create_random_initial_state_circuit(3) + config = AdaptConfig(method="brickwall") + compiler = AdaptCompiler(qc, adapt_config=config) + + result = compiler.compile() + + overlap = co.calculate_overlap_between_circuits(qc, result.circuit) + + self.assertGreater(overlap, 1 - DEFAULT_SUFFICIENT_COST) + + def test_given_brickwall_mode_and_all_options_when_compile_then_works(self): + qc = co.create_random_initial_state_circuit(3) + starting_circuit = QuantumCircuit(3) + starting_circuit.x([0, 2]) + initial_ansatz = QuantumCircuit(3) + initial_ansatz.ry(0.5, 0) + + config = AdaptConfig( + cost_improvement_num_layers=50, + max_layers_to_modify=5, + method="brickwall", + rotosolve_frequency=3, + ) + compiler = AdaptCompiler( + qc, + backend=MPS_SIM, + adapt_config=config, + custom_layer_2q_gate=ans.identity_resolvable(), + starting_circuit=starting_circuit, + rotosolve_fraction=0.8, + soften_global_cost=True, + initial_single_qubit_layer=True, + ) + + result = compiler.compile( + initial_ansatz=initial_ansatz, optimise_initial_ansatz=True + ) + + overlap = co.calculate_overlap_between_circuits(qc, result.circuit) + + self.assertGreater(overlap, 1 - DEFAULT_SUFFICIENT_COST) + + def test_given_brickwall_mode_then_qubit_pair_history_correct(self): + # Odd number of qubits + qc = QuantumCircuit(5) + expected_order = [(0, 1), (2, 3), (1, 2), (3, 4)] + config = AdaptConfig(max_layers=10, method="brickwall") + compiler = AdaptCompiler(qc, adapt_config=config) + [compiler._add_layer(i) for i in range(5 * len(expected_order))] + for i, pair in enumerate(compiler.qubit_pair_history): + expected_pair = expected_order[i % len(expected_order)] + self.assertEqual(pair, expected_pair) + + # Even number of qubits + qc = QuantumCircuit(4) + expected_order = [(0, 1), (2, 3), (1, 2)] + config = AdaptConfig(max_layers=10, method="brickwall") + compiler = AdaptCompiler(qc, adapt_config=config) + [compiler._add_layer(i) for i in range(5 * len(expected_order))] + for i, pair in enumerate(compiler.qubit_pair_history): + expected_pair = expected_order[i % len(expected_order)] + self.assertEqual(pair, expected_pair) + + def test_given_two_qubits_and_brickwall_mode_then_works(self): + qc = co.create_random_initial_state_circuit(2) + config = AdaptConfig(method="brickwall") + compiler = AdaptCompiler(qc, adapt_config=config) + result = compiler.compile() + for pair in result.qubit_pair_history: + self.assertEqual(pair, (0, 1)) + + def test_given_less_than_two_qubits_and_brickwall_mode_then_error(self): + qc = QuantumCircuit(1) + config = AdaptConfig(method="brickwall") + compiler = AdaptCompiler(qc, adapt_config=config) + with self.assertRaises(ValueError): + result = compiler.compile() diff --git a/utils/adapt-aqc/test/recompilers/test_approximate_compiler.py b/utils/adapt-aqc/test/recompilers/test_approximate_compiler.py new file mode 100644 index 0000000000000000000000000000000000000000..3332bd3134f6641792202517dc8aa45abb4bcf2a --- /dev/null +++ b/utils/adapt-aqc/test/recompilers/test_approximate_compiler.py @@ -0,0 +1,170 @@ +from unittest import TestCase +from unittest.mock import patch + +import numpy as np +from qiskit import QuantumCircuit + +import adaptaqc.utils.circuit_operations as co +from adaptaqc.backends.python_default_backends import QASM_SIM, SV_SIM, MPS_SIM +from adaptaqc.compilers.adapt.adapt_compiler import AdaptCompiler +from adaptaqc.compilers.approximate_compiler import ApproximateCompiler + + +@patch.multiple(ApproximateCompiler, __abstractmethods__=set()) +class TestApproximateCompiler(TestCase): + def setUp(self) -> None: + self.qc = QuantumCircuit(1) + + def test_when_init_with_mps_backend_then_mps_backend_flag_true(self): + self.assertTrue(ApproximateCompiler(self.qc, MPS_SIM).is_aer_mps_backend) + + def test_when_init_with_sv_backend_then_sv_backend_flag_true(self): + self.assertTrue(ApproximateCompiler(self.qc, SV_SIM).is_statevector_backend) + + def test_given_global_cost_and_SV_backend_when_evaluate_cost_then_correct_function_called( + self, + ): + compiler = ApproximateCompiler(QuantumCircuit(1), SV_SIM) + with patch.object(compiler.backend, "evaluate_global_cost") as mock: + compiler.evaluate_cost() + mock.assert_called_once() + + def test_given_global_cost_and_MPS_backend_when_evaluate_cost_then_correct_function_called( + self, + ): + compiler = ApproximateCompiler(QuantumCircuit(1), MPS_SIM) + with patch.object(compiler.backend, "evaluate_global_cost") as mock: + compiler.evaluate_cost() + mock.assert_called_once() + + def test_given_global_cost_and_QASM_backend_when_evaluate_cost_then_correct_function_called( + self, + ): + compiler = ApproximateCompiler(QuantumCircuit(1), QASM_SIM) + with patch.object(compiler.backend, "evaluate_global_cost") as mock: + compiler.evaluate_cost() + mock.assert_called_once() + + def test_given_local_cost_and_SV_backend_when_evaluate_cost_then_correct_function_called( + self, + ): + compiler = ApproximateCompiler( + QuantumCircuit(1), SV_SIM, optimise_local_cost=True + ) + with patch.object(compiler.backend, "evaluate_local_cost") as mock: + compiler.evaluate_cost() + mock.assert_called_once() + + def test_given_local_cost_and_MPS_backend_when_evaluate_cost_then_correct_function_called( + self, + ): + compiler = ApproximateCompiler( + QuantumCircuit(1), MPS_SIM, optimise_local_cost=True + ) + with patch.object(compiler.backend, "evaluate_local_cost") as mock: + compiler.evaluate_cost() + mock.assert_called_once() + + def test_given_local_cost_and_QASM_backend_when_evaluate_cost_then_correct_function_called( + self, + ): + compiler = ApproximateCompiler( + QuantumCircuit(1), QASM_SIM, optimise_local_cost=True + ) + with patch.object(compiler.backend, "evaluate_local_cost") as mock: + compiler.evaluate_cost() + mock.assert_called_once() + + def test_given_random_circuit_when_evaluate_local_cost_all_three_methods_return_same_cost( + self, + ): + qc = co.create_random_initial_state_circuit(4) + + compiler_sv = AdaptCompiler(qc, backend=SV_SIM, optimise_local_cost=True) + compiler_mps = AdaptCompiler(qc, backend=MPS_SIM, optimise_local_cost=True) + compiler_qasm = AdaptCompiler(qc, backend=QASM_SIM, optimise_local_cost=True) + + cost_sv = compiler_sv.evaluate_cost() + cost_mps = compiler_mps.evaluate_cost() + cost_qasm = compiler_qasm.evaluate_cost() + + # Looser pass threshold for qasm because it includes some form of noise + np.testing.assert_almost_equal(cost_sv, cost_mps, decimal=5) + np.testing.assert_almost_equal(cost_sv, cost_qasm, decimal=2) + np.testing.assert_almost_equal(cost_mps, cost_qasm, decimal=2) + + def test_given_random_circuit_when_evaluate_global_cost_all_three_methods_return_same_cost( + self, + ): + qc = co.create_random_initial_state_circuit(4) + + compiler_sv = AdaptCompiler(qc, backend=SV_SIM) + compiler_mps = AdaptCompiler(qc, backend=MPS_SIM) + compiler_qasm = AdaptCompiler(qc, backend=QASM_SIM) + + cost_sv = compiler_sv.evaluate_cost() + cost_mps = compiler_mps.evaluate_cost() + cost_qasm = compiler_qasm.evaluate_cost() + + # Looser pass threshold for qasm because it includes some form of noise + np.testing.assert_almost_equal(cost_sv, cost_mps, decimal=5) + np.testing.assert_almost_equal(cost_sv, cost_qasm, decimal=2) + np.testing.assert_almost_equal(cost_mps, cost_qasm, decimal=2) + + def test_given_simple_states_when_evaluate_global_and_local_costs_then_correct_value( + self, + ): + # Analytically calculable costs: + # |0000> global=0, local=0 + # |1010> global=1, local=1/2 + # 4-qubit GHZ global=1/2, local=1/2 + # |++++> global=15/16, local=1/2 + # Using equations 9 and 11 from arXiv:1908.04416 + + analytic_costs = [0, 0, 1, 1 / 2, 1 / 2, 1 / 2, 15 / 16, 1 / 2] + adapt_costs = [] + + zero = QuantumCircuit(4) + + neel = QuantumCircuit(4) + neel.x([0, 2]) + + ghz = QuantumCircuit(4) + ghz.h(0) + for i in range(3): + ghz.cx(0, i + 1) + + hadamard = QuantumCircuit(4) + hadamard.h([0, 1, 2, 3]) + + circuits = [zero, neel, ghz, hadamard] + + for circuit in circuits: + for optimise_local_cost in [False, True]: + compiler = AdaptCompiler( + circuit, backend=SV_SIM, optimise_local_cost=optimise_local_cost + ) + cost = compiler.evaluate_cost() + adapt_costs.append(cost) + + np.testing.assert_allclose(adapt_costs, analytic_costs) + + def test_given_random_circuit_when_calculate_cost_local_less_or_equal_to_global( + self, + ): + qc = co.create_random_initial_state_circuit(4) + + compiler_local = AdaptCompiler(qc, backend=SV_SIM, optimise_local_cost=True) + compiler_global = AdaptCompiler(qc, backend=SV_SIM) + + cost_local = compiler_local.evaluate_cost() + cost_global = compiler_global.evaluate_cost() + + self.assertLessEqual(cost_local, cost_global) + + @patch("qiskit.QuantumCircuit.set_matrix_product_state") + def test_set_matrix_product_state_used_when_mps_backend( + self, mock_set_matrix_product_state + ): + ApproximateCompiler(QuantumCircuit(1), MPS_SIM) + mock_set_matrix_product_state.assert_called_once() diff --git a/utils/adapt-aqc/test/utils/__init__.py b/utils/adapt-aqc/test/utils/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/utils/adapt-aqc/test/utils/circuit_operations/__init__.py b/utils/adapt-aqc/test/utils/circuit_operations/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/utils/adapt-aqc/test/utils/circuit_operations/test_circuit_operations_basic.py b/utils/adapt-aqc/test/utils/circuit_operations/test_circuit_operations_basic.py new file mode 100644 index 0000000000000000000000000000000000000000..26cdd179e6957e31acdaaef42448d1fa90af1b5a --- /dev/null +++ b/utils/adapt-aqc/test/utils/circuit_operations/test_circuit_operations_basic.py @@ -0,0 +1,206 @@ +from unittest import TestCase + +import numpy as np +from qiskit import QuantumCircuit, QuantumRegister +from qiskit.circuit import Gate +from qiskit.circuit.library import RXGate + +import adaptaqc.utils.circuit_operations as co +import adaptaqc.utils.constants as vconstants + + +def create_circuit_with_dep_and_indep_params(): + qc = QuantumCircuit(1) + gate_1 = co.create_independent_parameterised_gate("rx", "theta_1", 0.2) + gate_2 = co.create_independent_parameterised_gate("ry", "theta_2", 0.6) + gate_3 = co.create_independent_parameterised_gate("rz", "theta_3", -0.1) + # create a gate which depends on gate_1 + gate_4 = co.create_dependent_parameterised_gate("rz", "-2*theta_1+0.3", 0) + co.add_gate(qc, gate_1, qubit_indexes=[0]) + co.add_gate(qc, gate_2, qubit_indexes=[0]) + co.add_gate(qc, gate_3, qubit_indexes=[0]) + co.add_gate(qc, gate_4, qubit_indexes=[0]) + # Find independent parameter values and update gate_4 + vals = co.calculate_independent_variable_values(qc) + co.reevaluate_dependent_parameterised_gates(qc, vals) + return qc + + +class TestCircuitOperationsBasic(TestCase): + def test_create_1q_gate(self): + rx_gate = co.create_1q_gate("rx", 0.5) + ry_gate = co.create_1q_gate("ry", -0.5) + rz_gate = co.create_1q_gate("rz", 0.23) + + self.assertEqual(rx_gate.name, "rx") + self.assertEqual(rx_gate.params[0], 0.5) + self.assertEqual(rx_gate.label, "rx") + + self.assertEqual(ry_gate.name, "ry") + self.assertEqual(ry_gate.params[0], -0.5) + self.assertEqual(ry_gate.label, "ry") + + self.assertEqual(rz_gate.name, "rz") + self.assertEqual(rz_gate.params[0], 0.23) + self.assertEqual(rz_gate.label, "rz") + + def test_create_2q_gate(self): + cx_gate = co.create_2q_gate("cx") + cz_gate = co.create_2q_gate("cz") + self.assertEqual(cx_gate.name, "cx") + self.assertEqual(cz_gate.name, "cz") + + def test_add_gate(self): + qc = QuantumCircuit(2) + qc.x(0) + qc.y(0) + qc.h(1) + + gate = RXGate(0.1, label="test_gate") + co.add_gate(qc, gate, 1, [0]) + + self.assertEqual(qc.data[1][0].label, "test_gate") + + def test_replace_1q_gate(self): + qc = QuantumCircuit(3) + qc.h(0) + qc.cx(0, 1) + qc.rx(0.3, 1) + qc.z(2) + co.replace_1q_gate(qc, 2, "rz", 1.2) + self.assertEqual(qc.data[2][0].label, "rz") + self.assertEqual(qc.data[2][0].params[0], 1.2) + + def test_replace_2q_gate(self): + qc = QuantumCircuit(3) + qc.h(0) + qc.cx(0, 1) + qc.rx(0.5, 1) + qc.z(2) + co.replace_2q_gate(qc, 1, 1, 2, "cz") + # qc.data has form [(gate,qargs,cargs)] where qargs,cargs have form + # [Qubit] + + self.assertEqual(qc.data[1][0].name, "cz") + self.assertEqual(qc.data[1][1][0]._index, 1) + self.assertEqual(qc.data[1][1][1]._index, 2) + + def test_is_supported_1q_gate(self): + assert co.is_supported_1q_gate(Gate("rx", 1, [0.5], "rx")) is True + assert ( + co.is_supported_1q_gate(Gate("rx", 1, [0.5], vconstants.FIXED_GATE_LABEL)) + is False + ) + assert co.is_supported_1q_gate(Gate("cx", 2, [])) is False + assert co.is_supported_1q_gate(Gate("ZZ", 1, [0.5])) is False + + def test_add_appropritate_gates(self): + qc = QuantumCircuit(2) + qc.x(0) + qc.y(1) + qc.h(0) + qc.s(1) + + # Test thinly dressed case + circ = qc.copy() + co.add_appropriate_gates(circ, 0, True, 2) + self.assertEqual(circ.data[2][0].name, "rz") + self.assertEqual(len(circ.data), 5) + + # Test fully dressed case + circ = qc.copy() + co.add_appropriate_gates(circ, 0, False, 2) + self.assertEqual(circ.data[2][0].name, "rz") + self.assertEqual(circ.data[3][0].name, "ry") + self.assertEqual(circ.data[4][0].name, "rz") + self.assertEqual(len(circ.data), 7) + + def test_add_dressed_cnot(self): + qr = QuantumRegister(3) + qc = QuantumCircuit(qr) + qc.h(0) + qc.cx(0, 1) + qc.rx(0.5, 1) + # Add dressed CNOT here + qc.h(1) + qc.z(2) + + expected_qc = QuantumCircuit(qr) + expected_qc.h(0) + expected_qc.cx(0, 1) + expected_qc.rx(0.5, 1) + # Before control rzryrz decomposition + expected_qc.rz(0, 1) + expected_qc.ry(0, 1) + expected_qc.rz(0, 1) + # CNOT + expected_qc.cx(1, 2) + # After target rzryrz decomposition + expected_qc.rz(0, 2) + expected_qc.ry(0, 2) + expected_qc.rz(0, 2) + expected_qc.h(1) + expected_qc.z(2) + + co.add_dressed_cnot(qc, 1, 2, gate_index=3, v2=False, v3=False) + assert co.are_circuits_identical(qc, expected_qc) + + # Test thinly dressed CNOT + expected_qc.rz(0, 2) + expected_qc.rz(0, 0) + expected_qc.cx(2, 0) + expected_qc.rz(0, 2) + expected_qc.rz(0, 0) + + co.add_dressed_cnot(qc, 2, 0, thinly_dressed=True) + assert co.are_circuits_identical(qc, expected_qc) + + def test_create_independent_parametrised_gate(self): + gate = co.create_independent_parameterised_gate("rx", "theta_0", 0.2) + + self.assertEqual(gate.name, "rx") + self.assertEqual(gate.label, "rx#theta_0") + self.assertEqual(gate.params, [0.2]) + + def test_create_dependent_parametrised_gate(self): + gate = co.create_dependent_parameterised_gate("rx", "-theta_0", 0.2) + + self.assertEqual(gate.name, "rx") + self.assertEqual(gate.label, "rx@-theta_0") + self.assertEqual(gate.params, [0.2]) + + def test_calculate_independent_variable_values(self): + qc = QuantumCircuit(1) + gate_1 = co.create_independent_parameterised_gate("rx", "theta_1", 0.2) + gate_2 = co.create_independent_parameterised_gate("ry", "theta_2", 0.6) + gate_3 = co.create_independent_parameterised_gate("rz", "theta_3", -0.1) + # dependent gates should not be counted + gate_4 = co.create_dependent_parameterised_gate("rz", "theta_4", 0.7) + co.add_gate(qc, gate_1, qubit_indexes=[0]) + co.add_gate(qc, gate_2, qubit_indexes=[0]) + co.add_gate(qc, gate_3, qubit_indexes=[0]) + co.add_gate(qc, gate_4, qubit_indexes=[0]) + + expected = {"theta_1": 0.2, "theta_2": 0.6, "theta_3": -0.1} + self.assertEqual(co.calculate_independent_variable_values(qc), expected) + + def test_reevaluate_dependent_parameterised_gates(self): + qc = create_circuit_with_dep_and_indep_params() + + # Now angle of gate_4 should be -2*0.2+0.3 = -0.1 + np.testing.assert_almost_equal(qc.data[3][0].params, -0.1, 10) + + def test_add_subscript_to_all_variables(self): + qc = create_circuit_with_dep_and_indep_params() + + # Add a subscript "A" to all gate variables + co.add_subscript_to_all_variables(qc, "A") + + expected = [ + "rx#theta_1_A", + "ry#theta_2_A", + "rz#theta_3_A", + "rz@-2*theta_1_A+0.3", + ] + for i in range(len(qc.data)): + self.assertEqual(qc.data[i][0].label, expected[i]) diff --git a/utils/adapt-aqc/test/utils/circuit_operations/test_circuit_operations_circuit_division.py b/utils/adapt-aqc/test/utils/circuit_operations/test_circuit_operations_circuit_division.py new file mode 100644 index 0000000000000000000000000000000000000000..a16530bbc2d7a57e5d0a732617348f5dd40de56f --- /dev/null +++ b/utils/adapt-aqc/test/utils/circuit_operations/test_circuit_operations_circuit_division.py @@ -0,0 +1,55 @@ +from unittest import TestCase + +from qiskit import QuantumCircuit, QuantumRegister, ClassicalRegister +from qiskit.circuit.library import RYGate, CXGate, CZGate + +import adaptaqc.utils.circuit_operations as co + + +class TestCircuitOperationsCircuitDivision(TestCase): + def test_find_previous_gate_on_qubit(self): + qc = QuantumCircuit(3) + qc.h(0) # index 0 + qc.ry(0.4, 1) # index 1 + qc.x(2) # index 2 + qc.cx(0, 1) # index 3 + qc.cz(1, 2) # index 4 + qc.h(1) # index 5 + expected = [ + (None, None), + (None, None), + (None, None), + (RYGate(0.4), 1), + (CXGate(), 3), + (CZGate(), 4), + ] + for i in range(len(qc.data)): + self.assertEqual(co.find_previous_gate_on_qubit(qc, i), expected[i]) + + def test_index_of_bit_in_circuit(self): + qr = QuantumRegister(8) + cr = ClassicalRegister(4) + + qc = QuantumCircuit(qr, cr) + + indexes = [] + for qubit in qr: + indexes.append(co.index_of_bit_in_circuit(qubit, qc)) + for clbit in cr: + indexes.append(co.index_of_bit_in_circuit(clbit, qc)) + + expected = [0, 1, 2, 3, 4, 5, 6, 7, 0, 1, 2, 3] + + self.assertEqual(indexes, expected) + + def test_vertically_divide_circuit(self): + qc = co.create_random_initial_state_circuit(5) + + sub_circuits = co.vertically_divide_circuit(qc, 3) + + reconstructed_qc = QuantumCircuit(5) + for circuit in sub_circuits: + co.add_to_circuit(reconstructed_qc, circuit) + self.assertLessEqual(circuit.depth(), 3) + + self.assertEqual(qc.data, reconstructed_qc.data) diff --git a/utils/adapt-aqc/test/utils/circuit_operations/test_circuit_operations_full_circuit.py b/utils/adapt-aqc/test/utils/circuit_operations/test_circuit_operations_full_circuit.py new file mode 100644 index 0000000000000000000000000000000000000000..50a5df325c06f5c0a9c14ff000919fa37aed1a2c --- /dev/null +++ b/utils/adapt-aqc/test/utils/circuit_operations/test_circuit_operations_full_circuit.py @@ -0,0 +1,324 @@ +from unittest import TestCase + +import numpy as np +from qiskit import QuantumCircuit, QuantumRegister, ClassicalRegister +from qiskit.circuit import Instruction +from qiskit.compiler import transpile +from qiskit.quantum_info import Statevector + +import adaptaqc.utils.circuit_operations as co + + +def create_test_circuit_1(qr): + full_circuit = QuantumCircuit(qr) + full_circuit.h(0) + full_circuit.cx(0, 1) + full_circuit.rx(-1.5, 2) + full_circuit.cx(2, 1) + full_circuit.rz(2.1, 1) + full_circuit.cx(1, 2) + full_circuit.ry(0.2, 1) + full_circuit.rx(0.23, 0) + full_circuit.cx(1, 2) + full_circuit.h(1) + return full_circuit + + +def create_test_circuit_2(): + qr = QuantumRegister(3) + left_circuit = QuantumCircuit(qr) + left_circuit.h(0) + left_circuit.cx(0, 1) + # Add right circuit will be added at this location + left_circuit.rx(0.23, 0) + left_circuit.cx(1, 2) + left_circuit.h(1) + return left_circuit, qr + + +def create_test_circuit_3(): + qr = QuantumRegister(3) + full_circuit = create_test_circuit_1(qr) + partial_circuit = QuantumCircuit(3) + partial_circuit.rx(-1.5, 2) + partial_circuit.cx(2, 1) + partial_circuit.rz(2.1, 1) + partial_circuit.cx(1, 2) + return full_circuit, partial_circuit + + +def create_test_circuit_4(): + qreg = QuantumRegister(3) + creg = ClassicalRegister(1) + quantum_circuit = QuantumCircuit(qreg, creg) + quantum_circuit.cx(0, 2) + quantum_circuit.h(0) + quantum_circuit.cx(2, 1) + quantum_circuit.rx(2.3, 1) + return creg, qreg, quantum_circuit + + +class TestOperationsFullCircuit(TestCase): + def test_find_register(self): + qreg = QuantumRegister(2, "q1") + creg = ClassicalRegister(2, "c0") + qc = QuantumCircuit(qreg, creg) + + self.assertEqual(co.find_register(qc, qc.qubits[0]), QuantumRegister(2, "q1")) + self.assertEqual(co.find_register(qc, qc.clbits[0]), ClassicalRegister(2, "c0")) + + def test_find_bit_index(self): + qreg = QuantumRegister(2) + creg = ClassicalRegister(1) + qc = QuantumCircuit(qreg, creg) + + self.assertEqual(co.find_bit_index(qc.qregs[0], qc.qubits[0]), 0) + self.assertEqual(co.find_bit_index(qc.qregs[0], qc.qubits[1]), 1) + self.assertEqual(co.find_bit_index(qc.cregs[0], qc.clbits[0]), 0) + self.assertEqual(co.find_bit_index(qc.qregs[0], qc.clbits[0]), None) + + def test_create_random_circuit(self): + qc_default = co.create_random_circuit(2) + qc_custom = co.create_random_circuit(2, 6, ["rx"], ["cx"]) + + self.assertIsInstance(qc_default, QuantumCircuit) + self.assertIsInstance(qc_custom, QuantumCircuit) + self.assertEqual(qc_default.depth(), 5) + self.assertEqual(qc_custom.depth(), 6) + self.assertTrue(any(gate[0].name == "rx" for gate in qc_custom.data)) + self.assertTrue(any(gate[0].name == "cx" for gate in qc_custom.data)) + self.assertTrue(any(gate[0].name != "cz" for gate in qc_custom.data)) + + def test_change_circuit_register(self): + qr1 = QuantumRegister(4) + qr2 = QuantumRegister(3) + qc = QuantumCircuit(qr2) + qc.cx(0, 2) + qc.h(0) + qc.cx(2, 1) + qc.rx(2.3, 1) + qc.x(0) + + expected_qc = QuantumCircuit(qr1) + expected_qc.cx(3, 1) + expected_qc.h(3) + expected_qc.cx(1, 2) + expected_qc.rx(2.3, 2) + expected_qc.x(3) + qubit_mapping = {0: 3, 1: 2, 2: 1} + co.change_circuit_register(qc, qr1, qubit_mapping) + + self.assertTrue( + all( + gate[1] == expected_gate[1] + for gate, expected_gate in zip(qc.data, expected_qc.data) + ) + ) + # Make sure old register was removed from circuit + self.assertTrue(qr2 not in qc.qregs) + self.assertTrue(co.are_circuits_identical(qc, expected_qc)) + + def test_add_to_circuit_and_unroll_to_basis_gates_and_transpile(self): + left_circuit, qr = create_test_circuit_2() + + right_circuit = QuantumCircuit(2) + right_circuit.rx(-1.5, 1) + right_circuit.cx(1, 0) + right_circuit.rz(2.1, 0) + # The following two gates should cancel each other out when transpiling + right_circuit.cx(0, 1) + right_circuit.cx(0, 1) + right_circuit.cx(0, 1) + right_circuit.ry(0.2, 0) + + expected_full_circuit = create_test_circuit_1(qr) + + co.add_to_circuit( + left_circuit, + right_circuit, + location=2, + transpile_before_adding=True, + transpile_kwargs={"optimization_level": 1}, + qubit_subset=[1, 2], + ) + self.assertAlmostEqual( + co.calculate_overlap_between_circuits(left_circuit, expected_full_circuit), + 1, + ) + self.assertEqual(len(expected_full_circuit.data), len(left_circuit.data)) + self.assertTrue(co.are_circuits_identical(left_circuit, expected_full_circuit)) + + def test_remove_inner_circuit(self): + partial_circuit, qr = create_test_circuit_2() + + full_circuit = create_test_circuit_1(qr) + + gate_range_to_remove = (2, 7) + co.remove_inner_circuit(full_circuit, gate_range_to_remove) + self.assertEqual(len(full_circuit.data), len(partial_circuit.data)) + self.assertTrue(co.are_circuits_identical(partial_circuit, full_circuit)) + + def test_extract_inner_circuit(self): + full_circuit, partial_circuit = create_test_circuit_3() + partial_circuit.ry(0.2, 1) + + gate_range_to_extract = (2, 7) + inner_circuit = co.extract_inner_circuit(full_circuit, gate_range_to_extract) + self.assertLess(len(inner_circuit.data), len(full_circuit.data)) + self.assertTrue(co.are_circuits_identical(inner_circuit, partial_circuit)) + + def test_replace_inner_circuit(self): + full_circuit, partial_circuit = create_test_circuit_3() + partial_circuit.ry(1.5, 2) + + gate_range_to_replace = (2, 7) + new_circuit = full_circuit.copy() + co.replace_inner_circuit(new_circuit, partial_circuit, gate_range_to_replace) + self.assertIsInstance(new_circuit, QuantumCircuit) + self.assertFalse(co.are_circuits_identical(new_circuit, full_circuit)) + + def test_replace_inner_circuit_with_transpile_level_3(self): + full_circuit, partial_circuit = create_test_circuit_3() + partial_circuit.ry(1.5, 2) + + gate_range_to_replace = (2, 7) + replaced_circuit_no_transpilation = full_circuit.copy() + replaced_circuit_with_transpilation = full_circuit.copy() + co.replace_inner_circuit( + replaced_circuit_no_transpilation, + partial_circuit, + gate_range_to_replace, + ) + transpiled_partial_circuit = transpile( + partial_circuit, + basis_gates=["cx", "rx", "ry", "rz"], + optimization_level=2, + ) + co.replace_inner_circuit( + replaced_circuit_with_transpilation, + transpiled_partial_circuit, + gate_range_to_replace, + ) + self.assertAlmostEqual( + co.calculate_overlap_between_circuits( + replaced_circuit_no_transpilation, + replaced_circuit_with_transpilation, + ), + 1, + ) + + def test_find_num_gates(self): + qc = QuantumCircuit(3) + qc.rx(0.6, 0) + qc.cx(0, 1) + # Counting start + qc.rx(0.3, 1) + # Next 2 cx gates should cancel each other if transpiling + qc.ry(1.3, 0) + qc.cx(1, 0) + qc.cx(1, 0) + qc.cx(1, 0) + qc.rz(-2.3, 2) + qc.cz(2, 0) + # Counting end + qc.cx(0, 1) + qc.rx(0.3, 1) + + self.assertEqual(co.find_num_gates(None), (0, 0)) + self.assertEqual(co.find_num_gates(qc, False, gate_range=(2, 9)), (4, 3)) + self.assertEqual(co.find_num_gates(qc, True, {"optimization_level": 1}), (4, 5)) + + def test_append_to_instruction(self): + full_circuit, qr = create_test_circuit_2() + full_circuit_instruction = full_circuit.to_instruction() + + partial_circuit = QuantumCircuit(qr) + partial_circuit.h(0) + partial_circuit.cx(0, 1) + partial_circuit.rx(0.23, 0) + partial_circuit.cx(1, 2) + + h_gate = QuantumCircuit(qr) + h_gate.h(1) + final_circuit_instruction = co.append_to_instruction( + partial_circuit.to_instruction(), h_gate.to_instruction() + ) + self.assertIsInstance(final_circuit_instruction, Instruction) + self.assertAlmostEqual( + co.calculate_overlap_between_circuits( + final_circuit_instruction.definition, + full_circuit_instruction.definition, + ), + 1, + ) + + def test_remove_classical_operations_and_add_classical_operations(self): + creg, qreg, quantum_circuit = create_test_circuit_4() + + classical_and_quantum_circuit = QuantumCircuit(qreg, creg) + classical_and_quantum_circuit.cx(0, 2) + classical_and_quantum_circuit.h(0) + classical_and_quantum_circuit.cx(2, 1) + classical_and_quantum_circuit.rx(2.3, 1) + classical_and_quantum_circuit.measure(0, 0) + + classical_gates = classical_and_quantum_circuit.copy() + classical_gates = co.remove_classical_operations(classical_gates) + co.add_classical_operations(quantum_circuit, classical_gates) + self.assertTrue(any(gate[0].name == "measure" for gate in quantum_circuit.data)) + self.assertTrue( + co.are_circuits_identical(quantum_circuit, classical_and_quantum_circuit) + ) + + def test_make_quantum_only_circuit(self): + creg, qreg, classical_and_quantum_circuit = create_test_circuit_4() + classical_and_quantum_circuit.measure(0, 0) + + classical_circuit = co.make_quantum_only_circuit(classical_and_quantum_circuit) + self.assertIsInstance(classical_circuit, QuantumCircuit) + self.assertLess( + len(classical_circuit.data), len(classical_and_quantum_circuit.data) + ) + self.assertFalse( + any(gate[0].name == "measure" for gate in classical_circuit.data) + ) + + def test_circuit_by_inverting_circuit(self): + circuit = QuantumCircuit(3) + circuit.cx(0, 2) + circuit.h(0) + circuit.cx(2, 1) + circuit.rx(2.3, 1, label="my_label") + + inverted_circuit = co.circuit_by_inverting_circuit(circuit.copy()) + double_inverted_circuit = co.circuit_by_inverting_circuit(inverted_circuit) + self.assertTrue(co.are_circuits_identical(circuit, double_inverted_circuit)) + + def test_initial_state_to_circuit_and_unroll_to_basis_gates_and_remove_reset_gates( + self, + ): + # Test None + self.assertEqual(co.initial_state_to_circuit(None), None) + + # Test Vector + qubits = 3 + x = np.random.RandomState().standard_normal(2**qubits) + rand_state = x / np.linalg.norm(x) + + qc = QuantumCircuit(qubits) + qc.append(co.initial_state_to_circuit(rand_state), qc.qubits) + sv = Statevector(qc) + overlap_minus_1 = np.abs(np.abs(np.vdot(rand_state, sv)) - 1) + self.assertLess(overlap_minus_1, 1e-3) + + def test_create_random_initial_state_circuit(self): + qc, sv = co.create_random_initial_state_circuit(3, return_statevector=True) + + self.assertIsInstance(qc, QuantumCircuit) + self.assertIsInstance(sv, np.ndarray) + self.assertEqual(co.find_register(qc, qc.qubits[0]), QuantumRegister(3, "q")) + + def test_are_circuits_identical(self): + qc1 = co.create_random_initial_state_circuit(3) + qc2 = qc1.copy() + self.assertTrue(co.are_circuits_identical(qc1, qc2)) diff --git a/utils/adapt-aqc/test/utils/circuit_operations/test_circuit_operations_optimisation.py b/utils/adapt-aqc/test/utils/circuit_operations/test_circuit_operations_optimisation.py new file mode 100644 index 0000000000000000000000000000000000000000..626720bd8f25096695e36a756e2eb8781c9004e0 --- /dev/null +++ b/utils/adapt-aqc/test/utils/circuit_operations/test_circuit_operations_optimisation.py @@ -0,0 +1,34 @@ +from unittest import TestCase + +import numpy as np +from qiskit import QuantumCircuit + +import adaptaqc.utils.circuit_operations as co + + +class TestCircuitOperationsOptimisation(TestCase): + def test_remove_unnecessary_gates_from_circuit(self): + original_circuit = QuantumCircuit(3) + original_circuit.cx(0, 2) + original_circuit.cz(0, 2) + original_circuit.cz(0, 2) + original_circuit.h(0) + original_circuit.cx(2, 1) + original_circuit.cx(2, 1) + original_circuit.rx(2.3, 1) + original_circuit.rx(2.3, 1) + original_circuit.rx(2.3, 1) + original_circuit.rx(2.3, 1) + original_circuit.x(0) + + expected_circuit = QuantumCircuit(3) + expected_circuit.cx(0, 2) + expected_circuit.h(0) + expected_circuit.rz((np.pi / 2), 1) + expected_circuit.ry(2.9168146928204126, 1) + expected_circuit.rz((3 / 2) * np.pi, 1) + expected_circuit.x(0) + + co.remove_unnecessary_gates_from_circuit(original_circuit) + self.assertIsInstance(original_circuit, QuantumCircuit) + self.assertEqual(original_circuit, expected_circuit) diff --git a/utils/adapt-aqc/test/utils/circuit_operations/test_circuit_operations_running.py b/utils/adapt-aqc/test/utils/circuit_operations/test_circuit_operations_running.py new file mode 100644 index 0000000000000000000000000000000000000000..c31226ec553bba1c123efba1a44ee37f633f47df --- /dev/null +++ b/utils/adapt-aqc/test/utils/circuit_operations/test_circuit_operations_running.py @@ -0,0 +1,123 @@ +from unittest import TestCase + +import numpy as np +from aqc_research.mps_operations import mps_dot, mps_from_circuit +from qiskit import QuantumCircuit + +import adaptaqc.backends.aer_mps_backend +import adaptaqc.backends.python_default_backends +import adaptaqc.utils.circuit_operations as co + + +class TestCircuitOperationsRunning(TestCase): + def test_given_mps_sims_with_different_trunc_when_mps_from_circuit_then_different_mps( + self, + ): + sim1 = adaptaqc.backends.aer_mps_backend.mps_sim_with_args() + sim2 = adaptaqc.backends.aer_mps_backend.mps_sim_with_args( + mps_truncation_threshold=1e-1 + ) + + qc = co.create_random_circuit(10, 20) + + mps1 = mps_from_circuit(qc.copy(), sim=sim1) + mps2 = mps_from_circuit(qc.copy(), sim=sim2) + + self.assertLess(np.abs(mps_dot(mps1, mps2)) ** 2, 0.99) + + def test_given_mps_sims_with_different_chi_when_mps_from_circuit_then_different_mps( + self, + ): + sim1 = adaptaqc.backends.aer_mps_backend.mps_sim_with_args() + sim2 = adaptaqc.backends.aer_mps_backend.mps_sim_with_args(max_chi=2) + + qc = co.create_random_circuit(10, 20) + + mps1 = mps_from_circuit(qc.copy(), sim=sim1) + mps2 = mps_from_circuit(qc.copy(), sim=sim2) + + self.assertLess(np.abs(mps_dot(mps1, mps2)) ** 2, 0.99) + + def test_run_circuit_without_transpilation(self): + """ + run_circuit_without_transpilation has 3 possible return paths: + 1. if is_statevector_backend(backend)==True and return_statevector==True + 2. if is_statevector_backend(backend)==True and return_statevector==False + 3. if none of the above + """ + qc = QuantumCircuit(5) + qc.h(0) + for i in range(4): + qc.cx(i, i + 1) + + # 1. + data = co.run_circuit_without_transpilation( + qc.copy(), + backend=adaptaqc.backends.python_default_backends.SV_SIM, + return_statevector=True, + ) + sv = data.data + self.assertAlmostEqual(sv[0], 1 / np.sqrt(2)) + self.assertAlmostEqual(sv[-1], 1 / np.sqrt(2)) + + # 2. + counts = co.run_circuit_without_transpilation( + qc.copy(), backend=adaptaqc.backends.python_default_backends.SV_SIM + ) + self.assertAlmostEqual(counts["00000"] / sum(counts.values()), 0.5) + self.assertAlmostEqual(counts["11111"] / sum(counts.values()), 0.5) + + # 3. (must add measurement gates, otherwise no counts) + qc.measure_all() + counts = co.run_circuit_without_transpilation(qc.copy()) + sigma = 1 / np.sqrt(1024) + self.assertAlmostEqual( + counts["00000"] / sum(counts.values()), 0.5, delta=5 * sigma + ) + self.assertAlmostEqual( + counts["11111"] / sum(counts.values()), 0.5, delta=5 * sigma + ) + + def test_run_circuit_with_transpilation(self): + """ + test the same return paths as run_circuit_without_transpilation: + 1. if is_statevector_backend(backend)==True and return_statevector==True + 2. if is_statevector_backend(backend)==True and return_statevector==False + 3. if none of the above + + + NOTE this transpiles the input circuit (returned as a new circuit), + and then calls run_circuit_without_transpilation. However transpile, + and hence unroll_to_basis_gates, does not modify the circuit in place, + so run_circuit_with_transpilation does not modify the input circuit. + TODO: double check this is the intended functionality. + """ + qc = QuantumCircuit(5) + qc.h(0) + qc.s(0) + qc.sdg(0) + for i in range(4): + qc.cx(i, i + 1) + + # 1. + data = co.run_circuit_with_transpilation( + qc.copy(), + backend=adaptaqc.backends.python_default_backends.SV_SIM, + return_statevector=True, + ) + sv = data.data + self.assertAlmostEqual(abs(sv[0]) ** 2, 0.5) + self.assertAlmostEqual(abs(sv[-1]) ** 2, 0.5) + + # 2. + counts = co.run_circuit_with_transpilation( + qc.copy(), backend=adaptaqc.backends.python_default_backends.SV_SIM + ) + self.assertAlmostEqual(counts["00000"] / sum(counts.values()), 0.5) + self.assertAlmostEqual(counts["11111"] / sum(counts.values()), 0.5) + + # 3. (must add measurement gates, otherwise no counts) + qc.measure_all() + counts = co.run_circuit_with_transpilation(qc.copy()) + self.assertAlmostEqual(counts["00000"] / sum(counts.values()), 0.5, 1) + self.assertAlmostEqual(counts["11111"] / sum(counts.values()), 0.5, 1) diff --git a/utils/adapt-aqc/test/utils/circuit_operations/test_circuit_operations_variational.py b/utils/adapt-aqc/test/utils/circuit_operations/test_circuit_operations_variational.py new file mode 100644 index 0000000000000000000000000000000000000000..24e9f59407e6d1486ff1d101a6a3de2bf549cc59 --- /dev/null +++ b/utils/adapt-aqc/test/utils/circuit_operations/test_circuit_operations_variational.py @@ -0,0 +1,54 @@ +from unittest import TestCase + +from qiskit import QuantumCircuit, QuantumRegister + +import adaptaqc.utils.circuit_operations as co +import adaptaqc.utils.constants as vconstants + + +class TestCircuitOperationsVariational(TestCase): + def test_find_angles_in_circuit(self): + qc = QuantumCircuit(3) + qc.h(0) + qc.cx(0, 1) + qc.rx(0.23, 0) + qc.cx(1, 0) + qc.rz(3.1, 2) + qc.rx(2.9, 2) + instr = qc.data[-1] + instr.operation.label = vconstants.FIXED_GATE_LABEL + qc.data[-1] = instr + qc.cx(1, 2) + qc.ry(-1.4, 1) + qc.measure_all() + + self.assertEqual(co.find_angles_in_circuit(qc), [0.23, 3.1, -1.4]) + self.assertEqual(co.find_angles_in_circuit(qc, (4, 5)), [3.1]) + + def test_update_angles_in_circuit(self): + fixed_gate = co.create_1q_gate("rx", 2.3) + fixed_gate.label = vconstants.FIXED_GATE_LABEL + parameterized_gate1 = co.create_1q_gate("rz", 1.0) + parameterized_gate2 = co.create_1q_gate("ry", 1.0) + qr = QuantumRegister(3) + qc = QuantumCircuit(qr) + qc.h(0) + qc.append(parameterized_gate1.copy(), [qr[0]]) + qc.cx(0, 1) + qc.append(parameterized_gate1.copy(), [qr[0]]) + qc.append(fixed_gate, [qr[2]]) + qc.append(parameterized_gate1.copy(), [qr[1]]) + qc.z(2) + qc.append(parameterized_gate2.copy(), [qr[2]]) + + new_angles = [-1, 0, 0.5, 0.23] + co.update_angles_in_circuit(qc, new_angles) + + for index, angle in zip([1, 3, 5, 7], new_angles): + self.assertEqual(qc.data[index][0].params[0], angle) + + new_angles = [0.5, 0.23] + co.update_angles_in_circuit(qc, new_angles, (4, 8)) + + for index, angle in zip([5, 7], new_angles): + self.assertEqual(qc.data[index][0].params[0], angle) diff --git a/utils/adapt-aqc/test/utils/test_ansatzes.py b/utils/adapt-aqc/test/utils/test_ansatzes.py new file mode 100644 index 0000000000000000000000000000000000000000..70e616259b6946344396f3cab5e3bbbd1dd52d30 --- /dev/null +++ b/utils/adapt-aqc/test/utils/test_ansatzes.py @@ -0,0 +1,188 @@ +from unittest import TestCase + +from aqc_research.model_sp_lhs.trotter.trotter import trotter_circuit +from qiskit import QuantumCircuit + +import adaptaqc.utils.circuit_operations as co +from adaptaqc.backends.python_default_backends import SV_SIM, MPS_SIM +from adaptaqc.compilers import AdaptConfig +from adaptaqc.utils.ansatzes import ( + u4, + identity_resolvable, + fully_dressed_cnot, + thinly_dressed_cnot, + heisenberg, +) +from adaptaqc.utils.constants import DEFAULT_SUFFICIENT_COST +from adaptaqc.compilers.adapt.adapt_compiler import AdaptCompiler + + +class TestAnsatzes(TestCase): + def setUp(self): + self.ansatz_list = [ + u4, + thinly_dressed_cnot, + fully_dressed_cnot, + identity_resolvable, + heisenberg, + ] + + def test_given_custom_ansatz_when_compile(self): + for ansatz in self.ansatz_list: + with self.subTest(ansatz): + qc = co.create_random_initial_state_circuit(3, seed=1) + qc = co.unroll_to_basis_gates(qc) + adapt_compiler = AdaptCompiler(qc, custom_layer_2q_gate=ansatz()) + + result = adapt_compiler.compile() + + approx_circuit = result.circuit + + overlap = co.calculate_overlap_between_circuits(approx_circuit, qc) + assert overlap > 1 - DEFAULT_SUFFICIENT_COST + + def test_given_custom_ansatz_and_all_options_when_compile(self): + for ansatz in self.ansatz_list: + with self.subTest(ansatz): + qc = co.create_random_initial_state_circuit(3, seed=1) + qc = co.unroll_to_basis_gates(qc) + + start_qc = QuantumCircuit(3) + start_qc.h(range(3)) + + adapt_compiler = AdaptCompiler( + qc, + custom_layer_2q_gate=ansatz(), + starting_circuit=start_qc, + initial_single_qubit_layer=True, + backend=SV_SIM, + ) + + result = adapt_compiler.compile() + + approx_circuit = result.circuit + + overlap = co.calculate_overlap_between_circuits(approx_circuit, qc) + assert overlap > 1 - DEFAULT_SUFFICIENT_COST + + def test_given_custom_ansatz_when_add_layer_then_parameters_change(self): + for ansatz in self.ansatz_list: + with self.subTest(ansatz): + qc = co.create_random_initial_state_circuit(3, seed=0) + qc = co.unroll_to_basis_gates(qc) + adapt_compiler = AdaptCompiler(qc, custom_layer_2q_gate=ansatz()) + + adapt_compiler._add_layer(0) + + last_layer_instructions = adapt_compiler.full_circuit[-len(ansatz()):] + for i, instruction in enumerate(last_layer_instructions): + if instruction.operation.name != "cx": + if ansatz is u4 and i in [14, 17]: + # For U(4) ansatz, the final gates on each qubit can have optimal angle zero. Which one + # depends on numpy version (presumably rounding differences at ~machine precision) + pass + else: + self.assertNotEqual(instruction.operation.params[0], 0.0) + + def test_given_custom_ansatz_and_mps_backend_when_add_layer_then_layers_cached( + self, + ): + for ansatz in self.ansatz_list: + with self.subTest(ansatz): + qc = co.create_random_initial_state_circuit(3) + qc = co.unroll_to_basis_gates(qc) + adapt_compiler = AdaptCompiler( + qc, + custom_layer_2q_gate=ansatz(), + backend=MPS_SIM, + adapt_config=AdaptConfig(max_layers_to_modify=2), + ) + + adapt_compiler._add_layer(0) + self.assertEqual(len(adapt_compiler.full_circuit), 1 + len(ansatz())) + adapt_compiler._add_layer(1) + self.assertEqual(len(adapt_compiler.full_circuit), 1 + len(ansatz())) + + def test_given_custom_ansatz_when_add_layer_then_gate_types_as_expected(self): + # Expected cnot indices for ansatzes: [u4, thinly_dressed_cnot, + # fully_dressed_cnot, identity_resolvable] + # E.g. for a thinly-dressed cnot, gates 0, 1, 3, 4 are rotation gates, + # and gate 2 is a cnot, so expected_cnots[1] = [2] + expected_cnots = [[6, 9, 11], [2], [6], [2, 5]] + for ansatz, expected_cnots in zip(self.ansatz_list, expected_cnots): + with self.subTest(ansatz): + qc = co.create_random_initial_state_circuit(3, seed=2) + qc = co.unroll_to_basis_gates(qc) + adapt_compiler = AdaptCompiler(qc, custom_layer_2q_gate=ansatz()) + + adapt_compiler._add_layer(0) + adapt_compiler._add_layer(1) + + last_layer_instructions = adapt_compiler.full_circuit[-len(ansatz()):] + + for index in expected_cnots: + self.assertEqual( + last_layer_instructions[index].operation.name, "cx" + ) + + def test_given_custom_thinly_dressed_when_compile_same_as_default_behaviour(self): + qc = co.create_random_initial_state_circuit(3, seed=1) + qc = co.unroll_to_basis_gates(qc) + + default_adapt_compiler = AdaptCompiler(qc) + default_result = default_adapt_compiler.compile() + + custom_adapt_compiler = AdaptCompiler( + qc, custom_layer_2q_gate=thinly_dressed_cnot() + ) + custom_result = custom_adapt_compiler.compile() + + self.assertEqual(default_result.overlap, custom_result.overlap) + + def test_given_use_rotoselect_false_when_add_layer_then_rotation_axes_unchanged( + self, + ): + for ansatz in self.ansatz_list: + with self.subTest(ansatz): + qc = co.create_random_initial_state_circuit(3) + qc = co.unroll_to_basis_gates(qc) + adapt_compiler = AdaptCompiler( + qc, custom_layer_2q_gate=ansatz(), use_rotoselect=False + ) + + adapt_compiler._add_layer(0) + adapt_compiler._add_layer(1) + + last_layer_gates = adapt_compiler.full_circuit.data[-len(ansatz()):] + + for i, gate in enumerate(last_layer_gates): + self.assertEqual(gate[0].name, ansatz().data[i][0].name) + + def test_given_u4_or_fully_dressed_when_compile_without_rotoselect_then_works( + self, + ): + for ansatz in [u4, fully_dressed_cnot]: + with self.subTest(ansatz): + qc = co.create_random_initial_state_circuit(3) + qc = co.unroll_to_basis_gates(qc) + adapt_compiler = AdaptCompiler( + qc, custom_layer_2q_gate=ansatz(), use_rotoselect=False + ) + result = adapt_compiler.compile() + overlap = co.calculate_overlap_between_circuits(qc, result.circuit) + self.assertGreater(overlap, 1 - DEFAULT_SUFFICIENT_COST) + + def test_given_xxx_state_and_heisenberg_ansatz_when_compile_without_rotoselect_then_works( + self, + ): + qc = QuantumCircuit(4) + qc.x([0, 2]) + qc = trotter_circuit( + qc, dt=0.1, delta=1.0, num_trotter_steps=2, second_order=False + ) + adapt_compiler = AdaptCompiler( + qc, custom_layer_2q_gate=heisenberg(), use_rotoselect=False + ) + result = adapt_compiler.compile() + overlap = co.calculate_overlap_between_circuits(qc, result.circuit) + self.assertGreater(overlap, 1 - DEFAULT_SUFFICIENT_COST) diff --git a/utils/adapt-aqc/test/utils/test_entanglement_measures.py b/utils/adapt-aqc/test/utils/test_entanglement_measures.py new file mode 100644 index 0000000000000000000000000000000000000000..69d28a3de2fb383308765af76eec4a3f4d4cf370 --- /dev/null +++ b/utils/adapt-aqc/test/utils/test_entanglement_measures.py @@ -0,0 +1,112 @@ +from unittest import TestCase +from unittest.mock import patch + +import aqc_research.mps_operations as mpsops +import numpy as np +import qiskit.quantum_info +from qiskit import QuantumCircuit +from qiskit.quantum_info import random_statevector, DensityMatrix +from qiskit_aer import Aer + +import adaptaqc.backends.python_default_backends +import adaptaqc.utils.circuit_operations as co +import adaptaqc.utils.entanglement_measures as em +from adaptaqc.backends.python_default_backends import SV_SIM, MPS_SIM, QASM_SIM +from adaptaqc.compilers import AdaptCompiler +from adaptaqc.utils.entanglement_measures import ( + perform_quantum_tomography, + EM_TOMOGRAPHY_CONCURRENCE, + EM_TOMOGRAPHY_NEGATIVITY, + EM_TOMOGRAPHY_EOF, +) + + +class TestEntanglementMeasures(TestCase): + def test_quantum_tomography(self): + qc = co.create_random_initial_state_circuit(3) + dm = perform_quantum_tomography(qc, 0, 1, Aer.get_backend("qasm_simulator")) + assert isinstance(dm, np.ndarray) + + def test_tomography_entanglement_measures(self): + qc = co.create_random_initial_state_circuit(3) + for backend in [ + QASM_SIM, + SV_SIM, + ]: + for method in [ + em.EM_TOMOGRAPHY_CONCURRENCE, + em.EM_TOMOGRAPHY_EOF, + em.EM_TOMOGRAPHY_NEGATIVITY, + ]: + em.calculate_entanglement_measure(method, qc, 0, 1, backend) + + def test_observable_min_concurrence(self): + qc = co.create_random_initial_state_circuit(3) + em.measure_concurrence_lower_bound(qc, 0, 1, QASM_SIM) + + def test_when_calculating_concurrence_then_matches_qiskit(self): + rho = DensityMatrix(random_statevector(4, seed=0)).data + self.assertAlmostEqual( + qiskit.quantum_info.concurrence(rho), em.concurrence(rho) + ) + + @patch("aqc_research.mps_operations.partial_trace") + def test_given_mps_backend_when_calculate_em_measure_then_mps_operations_partial_trace_called( + self, mock_mps_partial_trace + ): + mock_mps_partial_trace.return_value = np.ones((4, 4)) + + backend = MPS_SIM + qc = co.create_random_initial_state_circuit(3) + qc_mps = mpsops.mps_from_circuit(qc, print_log_data=False) + em.calculate_entanglement_measure( + em.EM_TOMOGRAPHY_CONCURRENCE, qc, 0, 1, backend, mps=qc_mps + ) + + mock_mps_partial_trace.assert_called_once_with( + qc_mps, [0, 1], already_preprocessed=True + ) + + @patch("adaptaqc.backends.aer_mps_backend.mps_from_circuit") + def test_given_mps_backend_when_get_all_qubit_pair_em_measure_then_mps_from_circuit_called_exactly_once( + self, mock_mps_from_circuit + ): + from numpy import array + + qc = QuantumCircuit(2) + circ_mps = ( + [ + (array([[1.0 + 0.0j]]), array([[0.0 + 0.0j]])), + (array([[1.0 + 0.0j]]), array([[0.0 + 0.0j]])), + ], + [array([1.0])], + ) + circ_mps = mpsops._preprocess_mps(circ_mps) + mock_mps_from_circuit.return_value = circ_mps + compiler = AdaptCompiler( + qc, backend=adaptaqc.backends.python_default_backends.MPS_SIM + ) + compiler.circ_mps = circ_mps + compiler._get_all_qubit_pair_entanglement_measures() + mock_mps_from_circuit.assert_called_once() + + def test_given_random_state_when_backend_mps_or_statevector_then_ent_measures_equal( + self, + ): + qc = co.create_random_initial_state_circuit(3) + + entanglement_measures = [ + EM_TOMOGRAPHY_CONCURRENCE, + EM_TOMOGRAPHY_NEGATIVITY, + EM_TOMOGRAPHY_EOF, + ] + + for i in entanglement_measures: + sv_compiler = AdaptCompiler(qc, entanglement_measure=i, backend=SV_SIM) + mps_compiler = AdaptCompiler(qc, entanglement_measure=i, backend=MPS_SIM) + + np.testing.assert_allclose( + sv_compiler._get_all_qubit_pair_entanglement_measures(), + mps_compiler._get_all_qubit_pair_entanglement_measures(), + atol=1e-06, + ) diff --git a/utils/adapt-aqc/test/utils/test_gradients.py b/utils/adapt-aqc/test/utils/test_gradients.py new file mode 100644 index 0000000000000000000000000000000000000000..35e6e946f0210071d4ff92c72af46817701fc9b5 --- /dev/null +++ b/utils/adapt-aqc/test/utils/test_gradients.py @@ -0,0 +1,204 @@ +from unittest import TestCase + +import numpy as np +from qiskit import QuantumCircuit +from qiskit.circuit.random import random_circuit +from qiskit.compiler import transpile +from qiskit.quantum_info import Statevector + +import adaptaqc.backends.python_default_backends +import adaptaqc.utils.ansatzes as ans +import adaptaqc.utils.gradients as gr + + +class TestGeneralGradOfPairs(TestCase): + def test_given_no_ansatz_then_no_gradient(self): + qc = transpile(random_circuit(5, 5), basis_gates=["cx", "ry", "rz", "rx"]) + starting_circuit = transpile( + random_circuit(5, 1), basis_gates=["cx", "ry", "rz", "rx"] + ) + + ansatz = QuantumCircuit(2) + + generators, degeneracies = gr.get_generators_and_degeneracies(ansatz) + sim = adaptaqc.backends.python_default_backends.MPS_SIM + gradients = gr.general_grad_of_pairs( + qc, + ansatz, + generators, + degeneracies, + coupling_map=[(0, 1), (1, 2), (2, 3), (3, 4)], + starting_circuit=starting_circuit, + backend=sim, + ) + + expected_gradients = [0.0, 0.0, 0.0, 0.0] + + np.testing.assert_array_almost_equal(gradients, expected_gradients) + + def test_given_random_state_and_rx_ry_ansatz_when_general_grad_then_as_expected( + self, + ): + """ + Given a random state [a,b,c,d] and an arbitrarily chosen ansatz: Rx(θ) on qubit 0 and Ry(ɸ) + on qubit 1, the analytical partial derivatives w.r.t θ and ɸ around θ=ɸ=0 are: + dC/dθ|θ,ɸ=0 = -Im(conj(a)b) + dC/dɸ|θ,ɸ=0 = Re(conj(a)c) + general_grad_of_pairs returns the Euclidean norm of this. + """ + qc = transpile(random_circuit(2, 5), basis_gates=["cx", "ry", "rz", "rx"]) + sv = Statevector(qc) + a = sv.data[0] + b = sv.data[1] + c = sv.data[2] + + # Calculate gradient analytically + theta_grad = -1 * np.imag(np.conj(a) * b) + phi_grad = np.real(np.conj(a) * c) + expected_grad = np.sqrt(theta_grad**2 + phi_grad**2) + + # Calculate gradient using general_grad_of_pairs + ansatz = QuantumCircuit(2) + ansatz.rx(0, 0) + ansatz.ry(0, 1) + + generators, degeneracies = gr.get_generators_and_degeneracies( + ansatz, rotoselect=False, inverse=True + ) + inverse_zero_ansatz = transpile(ansatz.inverse()) + actual_grad = gr.general_grad_of_pairs( + qc, inverse_zero_ansatz, generators, degeneracies, coupling_map=[(0, 1)] + )[0] + + self.assertAlmostEqual(expected_grad, actual_grad, places=10) + + +class TestGetGenerators(TestCase): + def test_given_random_ansatz_then_correct_sum_of_degeneracies(self): + ansatz = transpile(random_circuit(2, 3), basis_gates=["cx", "ry", "rz", "rx"]) + ops = ansatz.count_ops() + num_rotations = ops.get("rx", 0) + ops.get("ry", 0) + ops.get("rz", 0) + + _, degeneracies_no_rotoselect = gr.get_generators_and_degeneracies( + ansatz, rotoselect=False + ) + _, degeneracies_rotoselect = gr.get_generators_and_degeneracies( + ansatz, rotoselect=True + ) + + self.assertEqual(sum(degeneracies_no_rotoselect), num_rotations) + self.assertEqual(sum(degeneracies_rotoselect), 3 * num_rotations) + + def test_given_known_ansatz_then_correct_generators(self): + ansatz = QuantumCircuit(2) + ansatz.rx(0, 0) + ansatz.cx(0, 1) + + # All possible generator circuits + gen_0 = QuantumCircuit(2) + gen_0.x(0) + gen_0.cx(0, 1) + gen_1 = QuantumCircuit(2) + gen_1.y(0) + gen_1.cx(0, 1) + gen_2 = QuantumCircuit(2) + gen_2.z(0) + gen_2.cx(0, 1) + gen_3 = QuantumCircuit(2) + gen_3.cx(0, 1) + gen_3.x(0) + gen_4 = QuantumCircuit(2) + gen_4.cx(0, 1) + gen_4.y(0) + gen_5 = QuantumCircuit(2) + gen_5.cx(0, 1) + gen_5.z(0) + + generators_no_rotoselect, _ = gr.get_generators_and_degeneracies( + ansatz, rotoselect=False, inverse=False + ) + generators_rotoselect, _ = gr.get_generators_and_degeneracies( + ansatz, rotoselect=True, inverse=False + ) + inv_generators_no_rotoselect, _ = gr.get_generators_and_degeneracies( + ansatz, rotoselect=False, inverse=True + ) + inv_generators_rotoselect, _ = gr.get_generators_and_degeneracies( + ansatz, rotoselect=True, inverse=True + ) + + self.assertEqual(generators_no_rotoselect, [gen_0]) + self.assertEqual(generators_rotoselect, [gen_0, gen_1, gen_2]) + self.assertEqual(inv_generators_no_rotoselect, [gen_3]) + self.assertEqual(inv_generators_rotoselect, [gen_3, gen_4, gen_5]) + + def test_given_specific_inputs_then_get_generator_returns_correct_generator(self): + ansatz = QuantumCircuit(2) + ansatz.rx(0, 0) + ansatz.ry(0, 1) + ansatz.cx(0, 1) + ansatz.rz(0, 0) + ansatz.rx(0, 1) + ansatz.cx(1, 0) + ansatz.ry(0, 0) + ansatz.rz(0, 1) + ansatz.cx(1, 0) + + generator = gr.get_generator(ansatz, index=3, op="ry") + + expected_generator = QuantumCircuit(2) + expected_generator.cx(0, 1) + expected_generator.y(0) + + self.assertEqual(generator, expected_generator) + + def test_given_ansatz_with_degenerate_generators_then_correct_generators_and_degeneracies( + self, + ): + ansatz = QuantumCircuit(2) + ansatz.rx(0, 0) + ansatz.cx(0, 1) + ansatz.ry(0, 1) + ansatz.cx(0, 1) + ansatz.rx(0, 0) + + gen_0 = QuantumCircuit(2) + gen_0.x(0) + gen_1 = QuantumCircuit(2) + gen_1.cx(0, 1) + gen_1.y(1) + gen_1.cx(0, 1) + + generators, degeneracies = gr.get_generators_and_degeneracies(ansatz) + + self.assertEqual(generators, [gen_0, gen_1]) + self.assertEqual(degeneracies, [2, 1]) + + def test_given_default_ansatzes_then_correct_number_of_generators(self): + ansatzes = [ + ans.fully_dressed_cnot(), + ans.heisenberg(), + ans.identity_resolvable(), + ans.thinly_dressed_cnot(), + ans.u4(), + ] + + num_distinct_generators_no_rotoselect = [8, 5, 4, 4, 11] + total_num_generators_no_rotoselect = [12, 5, 6, 4, 15] + num_distinct_generators_rotoselect = [12, 15, 12, 12, 21] + total_num_generators_rotoselect = [36, 15, 18, 12, 45] + + for i, ansatz in enumerate(ansatzes): + # No rotoselect + generators, degeneracies = gr.get_generators_and_degeneracies( + ansatz, rotoselect=False + ) + self.assertEqual(len(generators), num_distinct_generators_no_rotoselect[i]) + self.assertEqual(sum(degeneracies), total_num_generators_no_rotoselect[i]) + + # Rotoselect + generators, degeneracies = gr.get_generators_and_degeneracies( + ansatz, rotoselect=True + ) + self.assertEqual(len(generators), num_distinct_generators_rotoselect[i]) + self.assertEqual(sum(degeneracies), total_num_generators_rotoselect[i]) diff --git a/utils/adapt-aqc/test/utils/test_utilityfunctions.py b/utils/adapt-aqc/test/utils/test_utilityfunctions.py new file mode 100644 index 0000000000000000000000000000000000000000..31bbee667a4d0642f126cbe6353008b4a5137840 --- /dev/null +++ b/utils/adapt-aqc/test/utils/test_utilityfunctions.py @@ -0,0 +1,550 @@ +from unittest import TestCase + +import aqc_research.mps_operations as mpsops +import numpy as np +from numpy.testing import assert_array_almost_equal +from qiskit import QuantumCircuit +from qiskit.quantum_info import Statevector +from tenpy import MPS, SpinChain, SpinSite, SpinHalfSite +from tenpy.models import XXZChain, TFIChain + +import adaptaqc.utils.circuit_operations as co +from adaptaqc.backends.python_default_backends import QASM_SIM, SV_SIM +from adaptaqc.utils.utilityfunctions import ( + _expectation_value_of_qubit, + expectation_value_of_qubits, + expectation_value_of_qubits_mps, + multi_qubit_gate_depth, + remove_permutations_from_coupling_map, + tenpy_to_qiskit_mps, + tenpy_chi_1_mps_to_circuit, + qiskit_to_tenpy_mps, + find_rotation_indices, + get_distinct_items_and_degeneracies, + tenpy_mps_to_statevector, + check_flipped_basis_states, +) + + +def get_random_tenpy_mps(num_sites=4, chi=None): + model_params = dict(L=num_sites, conserve="None") + + model = SpinChain(model_params) + tenpy_chi = 2 ** (num_sites // 2) if chi is None else chi + tenpy_mps = MPS.from_desired_bond_dimension(model.lat.mps_sites(), tenpy_chi) + return tenpy_mps + + +class TestUtilityFunctions(TestCase): + def test_qasm_when_zero_state_then_sigmaz_expectation_is_one(self): + qc = QuantumCircuit(1) + qc.measure_all() + + job = QASM_SIM.simulator.run(qc, shots=int(1e4)) + counts = job.result().get_counts() + eval_zero_state = _expectation_value_of_qubit(0, counts, 1) + self.assertAlmostEqual(eval_zero_state, 1) + + def test_qasm_when_zero_state_then_sigmaz_expectation_is_minus_one(self): + qc = QuantumCircuit(1) + qc.measure_all() + job = QASM_SIM.simulator.run(qc, shots=int(1e4)) + counts = job.result().get_counts() + eval_one_state = _expectation_value_of_qubit(0, counts, 1) + self.assertAlmostEqual(eval_one_state, 1) + + def test_qasm_multi_qubit_sigma_z_expectations(self): + qc = QuantumCircuit(3) + qc.x(0) + qc.h(1) + qc.measure_all() + job = QASM_SIM.simulator.run(qc, shots=int(1e4)) + counts = job.result().get_counts() + eval_one_plus_zero_state = expectation_value_of_qubits(counts) + five_sigma_error_range = 5 / np.sqrt(1e4) + equivalent_power_of_10 = -np.log10(five_sigma_error_range) + assert_array_almost_equal( + eval_one_plus_zero_state, [-1.0, 0.0, 1.0], decimal=equivalent_power_of_10 + ) + + def test_sv_when_zero_state_then_sigmaz_expectation_is_one(self): + qc = QuantumCircuit(1) + qc.measure_all() + job = SV_SIM.simulator.run(qc) + counts = job.result().get_counts() + eval_zero_state = _expectation_value_of_qubit(0, counts, 1) + self.assertAlmostEqual(eval_zero_state, 1.0) + + def test_sv_when_zero_state_then_sigmaz_expectation_is_minus_one(self): + qc = QuantumCircuit(1) + qc.measure_all() + job = SV_SIM.simulator.run(qc) + counts = job.result().get_counts() + eval_one_state = _expectation_value_of_qubit(0, counts, 1) + self.assertAlmostEqual(eval_one_state, 1.0) + + def test_sv_multi_qubit_sigma_z_expectations(self): + qc = QuantumCircuit(3) + qc.x(0) + qc.h(1) + job = SV_SIM.simulator.run(qc) + sv = job.result().get_statevector() + eval_one_plus_zero_state = expectation_value_of_qubits(sv) + assert_array_almost_equal( + eval_one_plus_zero_state, [-1.0, 0.0, 1.0], decimal=15 + ) + + def test_given_unique_coupling_map_when_remove_permutation_then_returned_in_same_order( + self, + ): + cmap = [(1, 2), (2, 3), (3, 4)] + self.assertEqual(remove_permutations_from_coupling_map(cmap), cmap) + + def test_given_coupling_map_with_permutations_when_remove_permutation_then_unique( + self, + ): + cmap = [(2, 1), (1, 2), (2, 1)] + self.assertEqual(remove_permutations_from_coupling_map(cmap), [(2, 1)]) + + def test_given_coupling_map_with_permutations_when_remove_permutation_then_same_order( + self, + ): + cmap = [(1, 2), (1, 2), (2, 3), (2, 3), (3, 4)] + self.assertEqual( + remove_permutations_from_coupling_map(cmap), [(1, 2), (2, 3), (3, 4)] + ) + + def test_find_rotation_indices(self): + qc = QuantumCircuit(3) + qc.x(0) + qc.y(1) + qc.cx(0, 2) + qc.rx(1.3, 0) + qc.ry(0.7, 0) + qc.cx(0, 1) + qc.rx(1.1, 2) + qc.rz(1.6, 2) + + indices = [0, 2, 3, 4, 5, 7] + + expected_rotation_indices = [3, 4, 7] + rotation_indices = find_rotation_indices(qc, indices) + + self.assertEqual(expected_rotation_indices, rotation_indices) + + def test_get_distinct_items_and_degeneracies(self): + # Integers + list_int = [1, 2, 3, 4, 4, 5, 0, 1, 1, 1, 3] + + distinct_list_int = [1, 2, 3, 4, 5, 0] + degeneracies_list_int = [4, 1, 2, 2, 1, 1] + + distinct_items, degeneracies = get_distinct_items_and_degeneracies(list_int) + + self.assertEqual(distinct_items, distinct_list_int) + self.assertEqual(degeneracies, degeneracies_list_int) + + # Strings + list_str = ["a", "a", "b", "c", "a", "b"] + + distinct_list_str = ["a", "b", "c"] + degeneracies_list_str = [3, 2, 1] + + distinct_items, degeneracies = get_distinct_items_and_degeneracies(list_str) + + self.assertEqual(distinct_items, distinct_list_str) + self.assertEqual(degeneracies, degeneracies_list_str) + + # QCs + qc_0 = co.create_random_initial_state_circuit(2) + qc_1 = co.create_random_initial_state_circuit(3) + qc_2 = co.create_random_initial_state_circuit(2) + + list_qc = [qc_1, qc_2, qc_0, qc_2, qc_2, qc_2] + + distinct_list_qc = [qc_1, qc_2, qc_0] + degeneracies_list_qc = [1, 4, 1] + + distinct_items, degeneracies = get_distinct_items_and_degeneracies(list_qc) + + self.assertEqual(distinct_items, distinct_list_qc) + self.assertEqual(degeneracies, degeneracies_list_qc) + + # Mixed + list_mix = ["a", 1, qc_0, "a", "a", 2, 4, qc_2, qc_0, "b", 1, "b"] + + distinct_list_mix = ["a", 1, qc_0, 2, 4, qc_2, "b"] + degeneracies_list_mix = [3, 2, 2, 1, 1, 1, 2] + + distinct_items, degeneracies = get_distinct_items_and_degeneracies(list_mix) + + self.assertEqual(distinct_items, distinct_list_mix) + self.assertEqual(degeneracies, degeneracies_list_mix) + + +class TestExpectationValueOfQubitsMPS(TestCase): + def test_given_circuit_when_mps_expectation_value_then_callable_twice(self): + """ + Qiskit cannot create a MPS for the same circuit object twice. This test checks that a copy + of the input circuit is made before generating the MPS + """ + qc = QuantumCircuit(4) + expectation_value_of_qubits_mps(qc) + expectation_value_of_qubits_mps(qc) + + def test_given_n_qubit_circuit_when_mps_expectation_value_then_n_output_values( + self, + ): + qc = QuantumCircuit(3) + self.assertEqual(len(expectation_value_of_qubits_mps(qc)), 3) + + def test_given_zero_state_mps_when_pauli_expectation_then_is_correct(self): + qc = QuantumCircuit(4) + expectation = expectation_value_of_qubits_mps(qc) + np.testing.assert_allclose(expectation, [1, 1, 1, 1]) + + def test_given_hadamard_state_mps_when_pauli_expectation_then_is_correct(self): + qc = QuantumCircuit(4) + for i in range(4): + qc.h(i) + expectation = expectation_value_of_qubits_mps(qc) + np.testing.assert_allclose(expectation, [0, 0, 0, 0], atol=1e-07) + + +class TestMultiQubitGateDepth(TestCase): + def test_given_no_gates_then_zero(self): + qc = QuantumCircuit(1) + self.assertEqual(multi_qubit_gate_depth(qc), 0) + + def test_given_single_qubit_gates_then_zero(self): + qc = QuantumCircuit(2) + qc.h(0) + qc.x(1) + self.assertEqual(multi_qubit_gate_depth(qc), 0) + + def test_given_single_cnot_then_one(self): + qc = QuantumCircuit(2) + qc.cx(0, 1) + self.assertEqual(multi_qubit_gate_depth(qc), 1) + + def test_given_multiple_cnots_overlapping_qubits_then_two(self): + qc = QuantumCircuit(3) + qc.cx(0, 1) + qc.cx(1, 2) + self.assertEqual(multi_qubit_gate_depth(qc), 2) + + def test_given_multiple_cnots_different_qubits_then_one(self): + qc = QuantumCircuit(4) + qc.cx(0, 1) + qc.cx(2, 3) + self.assertEqual(multi_qubit_gate_depth(qc), 1) + + def test_given_cnot_and_single_qubit_gates_then_one(self): + qc = QuantumCircuit(2) + qc.h(0) + qc.cx(0, 1) + qc.x(1) + self.assertEqual(multi_qubit_gate_depth(qc), 1) + + def test_given_nested_cnots_then_three(self): + qc = QuantumCircuit(3) + qc.cx(0, 1) + qc.cx(1, 2) + qc.cx(0, 2) + self.assertEqual(multi_qubit_gate_depth(qc), 3) + + +class TestTenpyToQiskitMPS(TestCase): + def test_given_tenpy_mps_then_not_qiskit_mps(self): + tenpy_mps = get_random_tenpy_mps() + is_qiskit_mps = mpsops.check_mps(tenpy_mps) + self.assertFalse(is_qiskit_mps) + + def test_given_tenpy_mps_when_convert_to_qiskit_mps_then_qiskit_mps(self): + tenpy_mps = get_random_tenpy_mps() + qiskit_mps = tenpy_to_qiskit_mps(tenpy_mps) + is_qiskit_mps = mpsops.check_mps(qiskit_mps) + self.assertTrue(is_qiskit_mps) + + def test_given_tenpy_to_qiskit_mps_then_mps_normalised(self): + tenpy_mps = get_random_tenpy_mps() + qiskit_mps = tenpy_to_qiskit_mps(tenpy_mps) + norm = np.sqrt(np.real(mpsops.mps_dot(qiskit_mps, qiskit_mps))) + self.assertAlmostEqual(norm, 1) + + def test_given_tenpy_mps_to_qiskit_mps_then_statevectors_equal(self): + n = 4 + tenpy_mps = get_random_tenpy_mps(n) + qiskit_mps = tenpy_to_qiskit_mps(tenpy_mps) + + # NOTE: tenpy uses big-endian notation. I.e. the state q0 = |1>, q1 = |0> would + # be |10> = [0, 0, 1, 0] in tenpy but |01> = [0, 1, 0, 0] in qiskit. + tenpy_sv = tenpy_mps_to_statevector(tenpy_mps) + qiskit_sv = mpsops.mps_to_vector(qiskit_mps) + + np.testing.assert_allclose(tenpy_sv, qiskit_sv) + + def test_given_neel_state_then_mps_from_tenpy_and_mps_from_circuit_equal(self): + n = 3 + # Neel state from tenpy + # NOTE: tenpy "down" is the same as qiskit |1> (-1 Sz value) + model = XXZChain( + { + "L": n, + "Jxx": 1.0, + "Jz": 1.0, + "hz": 0.0, + "bc_MPS": "finite", + } + ) + neel_state = ["up", "down", "up"] + tenpy_mps = MPS.from_product_state( + model.lat.mps_sites(), neel_state, bc=model.lat.bc_MPS + ) + qiskit_mps_from_tenpy = tenpy_to_qiskit_mps(tenpy_mps) + + # Neel state from QuantumCircuit + qc = QuantumCircuit(n) + qc.x(1) + qiskit_mps_from_circuit = mpsops.mps_from_circuit(qc) + + overlap = ( + np.abs(mpsops.mps_dot(qiskit_mps_from_tenpy, qiskit_mps_from_circuit)) ** 2 + ) + + self.assertEqual(overlap, 1) + + def test_given_tenpy_mps_when_initialise_qiskit_with_it_then_output_qiskit_mps_same( + self, + ): + n = 4 + tenpy_mps = get_random_tenpy_mps(n) + qiskit_mps = tenpy_to_qiskit_mps(tenpy_mps) + + mps_as_circuit = QuantumCircuit(n) + mps_as_circuit.set_matrix_product_state(qiskit_mps) + mps_after_circuit = mpsops.mps_from_circuit(mps_as_circuit) + + overlap = np.abs(mpsops.mps_dot(mps_after_circuit, qiskit_mps)) ** 2 + self.assertAlmostEqual(overlap, 1, places=10) + + for i in range(n): + gamma_before = np.array(qiskit_mps[0][i]) + gamma_after = np.array(mps_after_circuit[0][i]) + np.testing.assert_allclose(gamma_before, gamma_after) + if i < n - 1: + lambda_before = np.array(qiskit_mps[1][i]) + lambda_after = np.array(mps_after_circuit[1][i]) + np.testing.assert_allclose(lambda_before, lambda_after) + + +class TestTenpyChi1MPSToCircuit(TestCase): + def test_given_random_tenpy_mps_when_map_to_circuit_then_correct_number_of_gates( + self, + ): + mps = get_random_tenpy_mps(chi=1) + qc = tenpy_chi_1_mps_to_circuit(mps) + self.assertLess(len(qc), 13) + + def test_given_mps_with_chi_greater_than_1_then_error(self): + mps = get_random_tenpy_mps(chi=2) + with self.assertRaises(Exception): + tenpy_chi_1_mps_to_circuit(mps) + + def test_given_random_chi_1_tenpy_mps_when_map_to_circuit_then_fidelity_1_with_mps( + self, + ): + mps = get_random_tenpy_mps(chi=1) + qc = tenpy_chi_1_mps_to_circuit(mps) + + mps_from_tenpy = tenpy_to_qiskit_mps(mps) + mps_from_qc = mpsops.mps_from_circuit(qc) + + fidelity = np.abs(mpsops.mps_dot(mps_from_qc, mps_from_tenpy)) ** 2 + self.assertAlmostEqual(fidelity, 1) + + def test_given_random_large_chi_1_tenpy_mps_when_map_to_circuit_then_fidelity_1_with_mps( + self, + ): + mps = get_random_tenpy_mps(num_sites=100, chi=1) + qc = tenpy_chi_1_mps_to_circuit(mps) + + mps_from_tenpy = tenpy_to_qiskit_mps(mps) + mps_from_qc = mpsops.mps_from_circuit(qc) + + fidelity = np.abs(mpsops.mps_dot(mps_from_qc, mps_from_tenpy)) ** 2 + self.assertAlmostEqual(fidelity, 1) + + def test_given_random_compressed_chi_1_mps_when_map_to_circuit_then_fidelity_1_with_compress_mps( + self, + ): + mps = get_random_tenpy_mps(chi=4) + compression_options = { + "compression_method": "variational", + "trunc_params": {"chi_max": 1}, + "max_trunc_err": 1, + "max_sweeps": 100, + "min_sweeps": 50, + } + mps.compress(compression_options) + qc = tenpy_chi_1_mps_to_circuit(mps) + mps_from_tenpy = tenpy_to_qiskit_mps(mps) + mps_from_qc = mpsops.mps_from_circuit(qc) + + fidelity = np.abs(mpsops.mps_dot(mps_from_qc, mps_from_tenpy)) ** 2 + self.assertAlmostEqual(fidelity, 1) + + def test_given_neel_state_when_map_to_circuit_then_correct(self): + model = XXZChain({"L": 3}) + neel_state = ["up", "down", "up"] + mps = MPS.from_product_state(model.lat.mps_sites(), neel_state) + qc = tenpy_chi_1_mps_to_circuit(mps) + sv = Statevector(qc).data + expected_sv = np.array([0, 0, 1, 0, 0, 0, 0, 0]) + + np.testing.assert_allclose(sv, expected_sv, atol=1e-10) + + +class TestQiskitToTenpyMPS(TestCase): + def test_given_qiskit_to_tenpy_mps_then_mps_normalised(self): + for prep in [True, False]: + for return_form in ["SpinSite", "SpinHalfSite"]: + qc = co.create_random_initial_state_circuit(3) + qiskit_mps = mpsops.mps_from_circuit(qc, return_preprocessed=prep) + tenpy_mps = qiskit_to_tenpy_mps(qiskit_mps, return_form=return_form) + + self.assertAlmostEqual(tenpy_mps.norm, 1) + + def test_given_qiskit_mps_to_tenpy_mps_then_statevectors_equal(self): + for prep in [True, False]: + for return_form in ["SpinSite", "SpinHalfSite"]: + n = 4 + qc = co.create_random_initial_state_circuit(n) + qiskit_mps = mpsops.mps_from_circuit(qc, return_preprocessed=prep) + tenpy_mps = qiskit_to_tenpy_mps(qiskit_mps, return_form=return_form) + + tenpy_sv = tenpy_mps_to_statevector(tenpy_mps) + qiskit_sv = mpsops.mps_to_vector(qiskit_mps, already_preprocessed=prep) + + np.testing.assert_allclose(tenpy_sv, qiskit_sv) + + def test_given_neel_state_then_tenpy_mps_as_expected(self): + for prep in [True, False]: + for return_form in ["SpinSite", "SpinHalfSite"]: + n = 3 + qc = QuantumCircuit(n) + qc.x([0, 2]) + qiskit_mps = mpsops.mps_from_circuit(qc, return_preprocessed=prep) + tenpy_mps = qiskit_to_tenpy_mps(qiskit_mps, return_form=return_form) + + # Convert to sv + tenpy_sv = tenpy_mps_to_statevector(tenpy_mps) + + expected_sv = [0, 0, 0, 0, 0, 1, 0, 0] + + np.testing.assert_allclose(tenpy_sv, expected_sv) + + def test_converter_functions_are_inverses(self): + for prep in [True, False]: + n = 4 + # Tenpy -> Qiskit -> Tenpy + tenpy_mps = get_random_tenpy_mps(n) + qiskit_mps = tenpy_to_qiskit_mps(tenpy_mps) + if prep: + qiskit_mps = mpsops._preprocess_mps(qiskit_mps) + # Here we specifically use return_form="SpinSite", since get_random_tenpy_mps returns an + # MPS made from SpinSite. MPS.overlap should not be called for two MPS of different + # site-types + tenpy_mps_reconstructed = qiskit_to_tenpy_mps( + qiskit_mps, return_form="SpinSite" + ) + + fidelity = np.abs(tenpy_mps.overlap(tenpy_mps_reconstructed)) ** 2 + self.assertAlmostEqual(fidelity, 1) + + # Qiskit -> Tenpy -> Qiskit + for return_form in ["SpinSite", "SpinHalfSite"]: + qc = co.create_random_initial_state_circuit(n) + qiskit_mps = mpsops.mps_from_circuit(qc, return_preprocessed=prep) + tenpy_mps = qiskit_to_tenpy_mps(qiskit_mps, return_form=return_form) + qiskit_mps_reconstructed = tenpy_to_qiskit_mps(tenpy_mps) + if prep: + qiskit_mps_reconstructed = mpsops._preprocess_mps( + qiskit_mps_reconstructed + ) + + fidelity = ( + np.abs( + mpsops.mps_dot( + qiskit_mps, + qiskit_mps_reconstructed, + already_preprocessed=prep, + ) + ) + ** 2 + ) + self.assertAlmostEqual(fidelity, 1) + + +class TestTenpyUtils(TestCase): + def test_check_flipped_basis_states(self): + L = 3 + product_state = ["up", "down", "up"] + + # XXZChain should have flipped basis states + model = XXZChain({"L": L, "conserve": None}) + mps = MPS.from_product_state(model.lat.mps_sites(), product_state) + print(check_flipped_basis_states(mps)) + assert check_flipped_basis_states(mps) == [True] * L + + # SpinChain should have flipped basis states + model = SpinChain({"L": L, "conserve": None}) + mps = MPS.from_product_state(model.lat.mps_sites(), product_state) + print(check_flipped_basis_states(mps)) + assert check_flipped_basis_states(mps) == [True] * L + + # SpinSite should have flipped basis states + sites = [SpinSite(conserve=None)] * L + mps = MPS.from_product_state(sites, product_state) + print(check_flipped_basis_states(mps)) + assert check_flipped_basis_states(mps) == [True] * L + + # TFIChain should NOT have flipped basis states + model = TFIChain({"L": L, "conserve": None}) + mps = MPS.from_product_state(model.lat.mps_sites(), product_state) + print(check_flipped_basis_states(mps)) + assert check_flipped_basis_states(mps) == [False] * L + + # SpinHalfSite should NOT have flipped basis states + sites = [SpinHalfSite(conserve=None)] * L + mps = MPS.from_product_state(sites, product_state) + print(check_flipped_basis_states(mps)) + assert check_flipped_basis_states(mps) == [False] * L + + # A mixture of SpinSite and SpinHalfSite should have a mixture of flipped/not flipped basis + # states + sites = [ + SpinHalfSite(conserve=None), + SpinHalfSite(conserve=None), + SpinSite(conserve=None), + ] + mps = MPS.from_product_state(sites, product_state) + print(check_flipped_basis_states(mps)) + assert check_flipped_basis_states(mps) == [False, False, True] + + def test_tenpy_mps_to_statevector(self): + L = 3 + product_state = ["up", "up", "down"] + expected_sv = [0, 0, 0, 0, 1, 0, 0, 0] + + # (up, up, down) corresponds to Qiskit basis states (0, 0, 1), which is the bitstring: 100 + model = SpinChain({"L": L, "conserve": None}) + mps = MPS.from_product_state(model.lat.mps_sites(), product_state) + sv = tenpy_mps_to_statevector(mps) + np.testing.assert_allclose(sv, expected_sv) + + # The statevector should be independent of the model + sites = [SpinHalfSite(conserve=None)] * L + mps = MPS.from_product_state(sites, product_state) + sv = tenpy_mps_to_statevector(mps) + np.testing.assert_allclose(sv, expected_sv)