From 91be0d314301521d2c7508bb566197c9dab9792b Mon Sep 17 00:00:00 2001 From: Caleb Johnson Date: Thu, 31 Aug 2023 16:25:59 -0500 Subject: [PATCH] Add a public generate_cutting_experiments function (#385) * Add a public generate_cutting_experiments function * playing with diff * diff * diff * ruff * revert accidental change * diff * cleanup * cleanup * Update circuit_knitting/cutting/cutting_evaluation.py Co-authored-by: Jim Garrison * Fix tests * peer review * coverage * peer review * Update cutting_evaluation.py * Update cutting_evaluation.py * fix broken tests * Better error * fix miswording * update test * dont change tut2 * Update cutting_evaluation.py * fix strange error * Unnecessary conditional * Move generate_exp tests to test_evaluation until we decide on home * ruff * Create a new module for subexperiment generation * Update circuit_knitting/cutting/cutting_evaluation.py Co-authored-by: Jim Garrison * peer review * pydocstyle * Add release note * cleanup * weird doc rendering * peer review * Fix incorrect error message * Update circuit_knitting/cutting/cutting_experiments.py Co-authored-by: Jim Garrison * Update error msg * Update circuit_knitting/cutting/cutting_experiments.py Co-authored-by: Jim Garrison * peer review * Update circuit_knitting/cutting/cutting_evaluation.py Co-authored-by: Jim Garrison --------- Co-authored-by: Jim Garrison --- circuit_knitting/cutting/__init__.py | 3 + .../cutting/cutting_evaluation.py | 89 ++++++++--- .../cutting/cutting_experiments.py | 75 +++++++++ ...generate-experiments-2ac773442132c78d.yaml | 9 ++ test/cutting/test_cutting_evaluation.py | 6 +- test/cutting/test_cutting_experiments.py | 151 ++++++++++++++++++ 6 files changed, 310 insertions(+), 23 deletions(-) create mode 100644 circuit_knitting/cutting/cutting_experiments.py create mode 100644 releasenotes/notes/generate-experiments-2ac773442132c78d.yaml create mode 100644 test/cutting/test_cutting_experiments.py diff --git a/circuit_knitting/cutting/__init__.py b/circuit_knitting/cutting/__init__.py index b8f65f9eb..268123d27 100644 --- a/circuit_knitting/cutting/__init__.py +++ b/circuit_knitting/cutting/__init__.py @@ -27,6 +27,7 @@ partition_problem cut_gates decompose_gates + generate_cutting_experiments execute_experiments reconstruct_expectation_values @@ -87,6 +88,7 @@ PartitionedCuttingProblem, ) from .cutting_evaluation import execute_experiments, CuttingExperimentResults +from .cutting_experiments import generate_cutting_experiments from .cutting_reconstruction import reconstruct_expectation_values from .wire_cutting_transforms import cut_wires, expand_observables @@ -95,6 +97,7 @@ "partition_problem", "cut_gates", "decompose_gates", + "generate_cutting_experiments", "execute_experiments", "reconstruct_expectation_values", "PartitionedCuttingProblem", diff --git a/circuit_knitting/cutting/cutting_evaluation.py b/circuit_knitting/cutting/cutting_evaluation.py index 2f0a5cdf9..995aed8d9 100644 --- a/circuit_knitting/cutting/cutting_evaluation.py +++ b/circuit_knitting/cutting/cutting_evaluation.py @@ -13,7 +13,8 @@ from __future__ import annotations -from typing import Any, NamedTuple +from typing import NamedTuple +from collections import defaultdict from collections.abc import Sequence from itertools import chain @@ -69,15 +70,15 @@ def execute_experiments( sampling frequency Raises: - ValueError: The number of requested samples must be positive. + ValueError: The number of requested samples must be at least one. ValueError: The types of ``circuits`` and ``subobservables`` arguments are incompatible. ValueError: ``SingleQubitQPDGate``\ s are not supported in unseparable circuits. ValueError: The keys for the input dictionaries are not equivalent. ValueError: The input circuits may not contain any classical registers or bits. ValueError: If multiple samplers are passed, each one must be unique. """ - if num_samples <= 0: - raise ValueError("The number of requested samples must be positive.") + if not num_samples >= 1: + raise ValueError("The number of requested samples must be at least 1.") if isinstance(circuits, dict) and not isinstance(subobservables, dict): raise ValueError( @@ -119,9 +120,9 @@ def execute_experiments( # Generate the sub-experiments to run on backend ( - subexperiments, + _, coefficients, - sampled_frequencies, + subexperiments, ) = _generate_cutting_experiments( circuits, subobservables, @@ -241,9 +242,23 @@ def _append_measurement_circuit( def _generate_cutting_experiments( circuits: QuantumCircuit | dict[str | int, QuantumCircuit], observables: PauliList | dict[str | int, PauliList], - num_samples: int, -) -> tuple[list[list[list[QuantumCircuit]]], list[tuple[Any, WeightType]], list[float]]: - """Generate all the experiments to run on the backend and their associated coefficients.""" + num_samples: int | float, +) -> tuple[ + list[QuantumCircuit] | dict[str | int, list[QuantumCircuit]], + list[tuple[float, WeightType]], + list[list[list[QuantumCircuit]]], +]: + if isinstance(circuits, QuantumCircuit) and not isinstance(observables, PauliList): + raise ValueError( + "If the input circuits is a QuantumCircuit, the observables must be a PauliList." + ) + if isinstance(circuits, dict) and not isinstance(observables, dict): + raise ValueError( + "If the input circuits are contained in a dictionary keyed by partition labels, the input observables must also be represented by such a dictionary." + ) + if not num_samples >= 1: + raise ValueError("num_samples must be at least 1.") + # Retrieving the unique bases, QPD gates, and decomposed observables is slightly different # depending on the format of the execute_experiments input args, but the 2nd half of this function # can be shared between both cases. @@ -285,18 +300,33 @@ def _generate_cutting_experiments( # Sort samples in descending order of frequency sorted_samples = sorted(random_samples.items(), key=lambda x: x[1][0], reverse=True) - # Generate the outputs -- sub-experiments, coefficients, and frequencies - subexperiments: list[list[list[QuantumCircuit]]] = [] - coefficients = [] - sampled_frequencies = [] + # Generate the output experiments and weights + subexperiments_dict: dict[str | int, list[QuantumCircuit]] = defaultdict(list) + weights: list[tuple[float, WeightType]] = [] for z, (map_ids, (redundancy, weight_type)) in enumerate(sorted_samples): - subexperiments.append([]) actual_coeff = np.prod( [basis.coeffs[map_id] for basis, map_id in strict_zip(bases, map_ids)] ) sampled_coeff = (redundancy / num_samples) * (kappa * np.sign(actual_coeff)) - coefficients.append((sampled_coeff, weight_type)) - sampled_frequencies.append(redundancy) + weights.append((sampled_coeff, weight_type)) + map_ids_tmp = map_ids + for i, (subcircuit, label) in enumerate( + strict_zip(subcircuit_list, sorted(subsystem_observables.keys())) + ): + if is_separated: + map_ids_tmp = tuple(map_ids[j] for j in subcirc_map_ids[i]) + decomp_qc = decompose_qpd_instructions( + subcircuit, subcirc_qpd_gate_ids[i], map_ids_tmp + ) + so = subsystem_observables[label] + for j, cog in enumerate(so.groups): + meas_qc = _append_measurement_circuit(decomp_qc, cog) + subexperiments_dict[label].append(meas_qc) + + # Generate legacy subexperiments list + subexperiments_legacy: list[list[list[QuantumCircuit]]] = [] + for z, (map_ids, (redundancy, weight_type)) in enumerate(sorted_samples): + subexperiments_legacy.append([]) for i, (subcircuit, label) in enumerate( strict_zip(subcircuit_list, sorted(subsystem_observables.keys())) ): @@ -306,13 +336,22 @@ def _generate_cutting_experiments( decomp_qc = decompose_qpd_instructions( subcircuit, subcirc_qpd_gate_ids[i], map_ids_tmp ) - subexperiments[-1].append([]) + subexperiments_legacy[-1].append([]) so = subsystem_observables[label] for j, cog in enumerate(so.groups): meas_qc = _append_measurement_circuit(decomp_qc, cog) - subexperiments[-1][-1].append(meas_qc) + subexperiments_legacy[-1][-1].append(meas_qc) + + # If the input was a single quantum circuit, return the subexperiments as a list + subexperiments_out: list[QuantumCircuit] | dict[ + str | int, list[QuantumCircuit] + ] = dict(subexperiments_dict) + assert isinstance(subexperiments_out, dict) + if isinstance(circuits, QuantumCircuit): + assert len(subexperiments_out.keys()) == 1 + subexperiments_out = list(subexperiments_dict.values())[0] - return subexperiments, coefficients, sampled_frequencies + return subexperiments_out, weights, subexperiments_legacy def _run_experiments_batch( @@ -377,7 +416,17 @@ def _get_mapping_ids_by_partition( subcirc_map_ids.append([]) for i, inst in enumerate(circ.data): if isinstance(inst.operation, SingleQubitQPDGate): - decomp_id = int(inst.operation.label.split("_")[-1]) + try: + decomp_id = int(inst.operation.label.split("_")[-1]) + except (AttributeError, ValueError): + raise ValueError( + "SingleQubitQPDGate instances in input circuit(s) must have their " + 'labels suffixed with "_", where is the index of the cut ' + "relative to the other cuts in the circuit. For example, all " + "SingleQubitQPDGates belonging to the same cut, N, should have labels " + ' formatted as "_N". This allows SingleQubitQPDGates ' + "belonging to the same cut to be sampled jointly." + ) decomp_ids.add(decomp_id) subcirc_qpd_gate_ids[-1].append([i]) subcirc_map_ids[-1].append(decomp_id) diff --git a/circuit_knitting/cutting/cutting_experiments.py b/circuit_knitting/cutting/cutting_experiments.py new file mode 100644 index 000000000..586a2d220 --- /dev/null +++ b/circuit_knitting/cutting/cutting_experiments.py @@ -0,0 +1,75 @@ +# This code is a Qiskit project. + +# (C) Copyright IBM 2023. + +# 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. + +"""Functions for evaluating circuit cutting experiments.""" + +from __future__ import annotations + +from qiskit.circuit import QuantumCircuit +from qiskit.quantum_info import PauliList + +from .qpd import WeightType +from .cutting_evaluation import _generate_cutting_experiments + + +def generate_cutting_experiments( + circuits: QuantumCircuit | dict[str | int, QuantumCircuit], + observables: PauliList | dict[str | int, PauliList], + num_samples: int | float, +) -> tuple[ + list[QuantumCircuit] | dict[str | int, list[QuantumCircuit]], + list[tuple[float, WeightType]], +]: + r""" + Generate cutting subexperiments and their associated weights. + + If the input, ``circuits``, is a :class:`QuantumCircuit` instance, the + output subexperiments will be contained within a 1D array, and ``observables`` is + expected to be a :class:`PauliList` instance. + + If the input circuit and observables are specified by dictionaries with partition labels + as keys, the output subexperiments will be returned as a dictionary which maps each + partition label to a 1D array containing the subexperiments associated with that partition. + + In both cases, the subexperiment lists are ordered as follows: + + :math:`[sample_{0}observable_{0}, \ldots, sample_{0}observable_{N}, sample_{1}observable_{0}, \ldots, sample_{M}observable_{N}]` + + The weights will always be returned as a 1D array -- one weight for each unique sample. + + Args: + circuits: The circuit(s) to partition and separate + observables: The observable(s) to evaluate for each unique sample + num_samples: The number of samples to draw from the quasi-probability distribution. If set + to infinity, the weights will be generated rigorously rather than by sampling from + the distribution. + Returns: + A tuple containing the cutting experiments and their associated weights. + If the input circuits is a :class:`QuantumCircuit` instance, the output subexperiments + will be a sequence of circuits -- one for every unique sample and observable. If the + input circuits are represented as a dictionary keyed by partition labels, the output + subexperiments will also be a dictionary keyed by partition labels and containing + the subexperiments for each partition. + The weights are always a sequence of length-2 tuples, where each tuple contains the + weight and the :class:`WeightType`. Each weight corresponds to one unique sample. + + Raises: + ValueError: ``num_samples`` must be at least one. + ValueError: ``circuits`` and ``observables`` are incompatible types + ValueError: :class:`SingleQubitQPDGate` instances must have their cut ID + appended to the gate label so they may be associated with other gates belonging + to the same cut. + ValueError: :class:`SingleQubitQPDGate` instances are not allowed in unseparated circuits. + """ + subexperiments, weights, _ = _generate_cutting_experiments( + circuits, observables, num_samples + ) + return subexperiments, weights diff --git a/releasenotes/notes/generate-experiments-2ac773442132c78d.yaml b/releasenotes/notes/generate-experiments-2ac773442132c78d.yaml new file mode 100644 index 000000000..d1190a0c3 --- /dev/null +++ b/releasenotes/notes/generate-experiments-2ac773442132c78d.yaml @@ -0,0 +1,9 @@ +--- +features: + - | + Added a module, :mod:`circuit_knitting.cutting.cutting_experiments`, which is intended to hold + functions used for generating the quantum experiments needed for circuit cutting. This module + will initially hold one function, :func:`.generate_cutting_experiments`, + which can be used to generate quantum experiments, given an input circuit containing + :class:`.BaseQPDGate` instances, some observables, and a number + of times the joint quasi-probability distribution for the cuts should be sampled. diff --git a/test/cutting/test_cutting_evaluation.py b/test/cutting/test_cutting_evaluation.py index 469c56a9d..9cafb52a2 100644 --- a/test/cutting/test_cutting_evaluation.py +++ b/test/cutting/test_cutting_evaluation.py @@ -9,11 +9,11 @@ # copyright notice, and modified files need to carry a notice indicating # that they have been altered from the originals. -import pytest -import unittest +import unittest from copy import deepcopy +import pytest from qiskit.quantum_info import Pauli, PauliList from qiskit.result import QuasiDistribution from qiskit.primitives import Sampler as TerraSampler @@ -215,7 +215,7 @@ def test_execute_experiments(self): ) assert ( e_info.value.args[0] - == "The number of requested samples must be positive." + == "The number of requested samples must be at least 1." ) with self.subTest("Dict of non-unique samplers"): qc = QuantumCircuit(2) diff --git a/test/cutting/test_cutting_experiments.py b/test/cutting/test_cutting_experiments.py new file mode 100644 index 000000000..820964360 --- /dev/null +++ b/test/cutting/test_cutting_experiments.py @@ -0,0 +1,151 @@ +# This code is a Qiskit project. + +# (C) Copyright IBM 2023. + +# 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 unittest + +import pytest +import numpy as np +from qiskit.quantum_info import PauliList +from qiskit.circuit import QuantumCircuit +from qiskit.circuit.library.standard_gates import CXGate + +from circuit_knitting.cutting.qpd import ( + SingleQubitQPDGate, + TwoQubitQPDGate, + QPDBasis, +) +from circuit_knitting.cutting import generate_cutting_experiments +from circuit_knitting.cutting.qpd import WeightType +from circuit_knitting.cutting import partition_problem + + +class TestCuttingExperiments(unittest.TestCase): + def test_generate_cutting_experiments(self): + with self.subTest("simple circuit and observable"): + qc = QuantumCircuit(2) + qc.append( + TwoQubitQPDGate(QPDBasis.from_gate(CXGate()), label="cut_cx"), + qargs=[0, 1], + ) + comp_weights = [ + (0.5, WeightType.EXACT), + (0.5, WeightType.EXACT), + (0.5, WeightType.EXACT), + (-0.5, WeightType.EXACT), + (0.5, WeightType.EXACT), + (-0.5, WeightType.EXACT), + ] + subexperiments, weights = generate_cutting_experiments( + qc, PauliList(["ZZ"]), np.inf + ) + assert weights == comp_weights + assert len(weights) == len(subexperiments) + for exp in subexperiments: + assert isinstance(exp, QuantumCircuit) + + with self.subTest("simple circuit and observable as dict"): + qc = QuantumCircuit(2) + qc.append( + SingleQubitQPDGate( + QPDBasis.from_gate(CXGate()), label="cut_cx_0", qubit_id=0 + ), + qargs=[0], + ) + qc.append( + SingleQubitQPDGate( + QPDBasis.from_gate(CXGate()), label="cut_cx_0", qubit_id=1 + ), + qargs=[1], + ) + comp_weights = [ + (0.5, WeightType.EXACT), + (0.5, WeightType.EXACT), + (0.5, WeightType.EXACT), + (-0.5, WeightType.EXACT), + (0.5, WeightType.EXACT), + (-0.5, WeightType.EXACT), + ] + subexperiments, weights = generate_cutting_experiments( + {"A": qc}, {"A": PauliList(["ZY"])}, np.inf + ) + assert weights == comp_weights + assert len(weights) == len(subexperiments["A"]) + for circ in subexperiments["A"]: + assert isinstance(circ, QuantumCircuit) + + with self.subTest("test bad num_samples"): + qc = QuantumCircuit(4) + with pytest.raises(ValueError) as e_info: + generate_cutting_experiments(qc, PauliList(["ZZZZ"]), 0) + assert e_info.value.args[0] == "num_samples must be at least 1." + with pytest.raises(ValueError) as e_info: + generate_cutting_experiments(qc, PauliList(["ZZZZ"]), np.nan) + assert e_info.value.args[0] == "num_samples must be at least 1." + with self.subTest("test incompatible inputs"): + qc = QuantumCircuit(4) + with pytest.raises(ValueError) as e_info: + generate_cutting_experiments(qc, {"A": PauliList(["ZZZZ"])}, 4.5) + assert ( + e_info.value.args[0] + == "If the input circuits is a QuantumCircuit, the observables must be a PauliList." + ) + with pytest.raises(ValueError) as e_info: + generate_cutting_experiments({"A": qc}, PauliList(["ZZZZ"]), 4.5) + assert ( + e_info.value.args[0] + == "If the input circuits are contained in a dictionary keyed by partition labels, the input observables must also be represented by such a dictionary." + ) + with self.subTest("test bad label"): + qc = QuantumCircuit(2) + qc.append( + TwoQubitQPDGate(QPDBasis.from_gate(CXGate()), label="cut_cx"), + qargs=[0, 1], + ) + partitioned_problem = partition_problem( + qc, "AB", observables=PauliList(["ZZ"]) + ) + partitioned_problem.subcircuits["A"].data[0].operation.label = "newlabel" + + with pytest.raises(ValueError) as e_info: + generate_cutting_experiments( + partitioned_problem.subcircuits, + partitioned_problem.subobservables, + np.inf, + ) + assert e_info.value.args[0] == ( + "SingleQubitQPDGate instances in input circuit(s) must have their " + 'labels suffixed with "_", where is the index of the cut ' + "relative to the other cuts in the circuit. For example, all " + "SingleQubitQPDGates belonging to the same cut, N, should have labels " + ' formatted as "_N". This allows SingleQubitQPDGates ' + "belonging to the same cut to be sampled jointly." + ) + with self.subTest("test bad observable size"): + qc = QuantumCircuit(4) + with pytest.raises(ValueError) as e_info: + generate_cutting_experiments(qc, PauliList(["ZZ"]), np.inf) + assert e_info.value.args[0] == ( + "Quantum circuit qubit count (4) does not match qubit count of observable(s) (2)." + " Try providing `qubit_locations` explicitly." + ) + with self.subTest("test single qubit qpd gate in unseparated circuit"): + qc = QuantumCircuit(2) + qc.append( + SingleQubitQPDGate(QPDBasis.from_gate(CXGate()), 0, label="cut_cx_0"), + qargs=[0], + ) + with pytest.raises(ValueError) as e_info: + generate_cutting_experiments(qc, PauliList(["ZZ"]), np.inf) + assert ( + e_info.value.args[0] + == "SingleQubitQPDGates are not supported in unseparable circuits." + )