diff --git a/CHANGELOG.md b/CHANGELOG.md index d44261a1a..8016254c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,12 +10,17 @@ [(#406)](https://github.com/PennyLaneAI/pennylane-qiskit/pull/406) [(#428)](https://github.com/PennyLaneAI/pennylane-qiskit/pull/428) -* Measurement operations are now added to the PennyLane template when a `QuantumCircuit` +* Measurement operations are now added to the PennyLane template when a ``QuantumCircuit`` is converted using `load`. Additionally, one can override any existing terminal measurements by providing a list of PennyLane `measurements `_ themselves. [(#405)](https://github.com/PennyLaneAI/pennylane-qiskit/pull/405) +* Added support for coverting conditional operations based on mid-circuit measurements and + two of the ``ControlFlowOp`` operations - ``IfElseOp`` and ``SwitchCaseOp`` when converting + a ``QuantumCircuit`` using `load`. + [(#417)](https://github.com/PennyLaneAI/pennylane-qiskit/pull/417) + ### Breaking changes 💔 ### Deprecations 👋 diff --git a/pennylane_qiskit/converter.py b/pennylane_qiskit/converter.py index 90c969527..b65a20b3e 100644 --- a/pennylane_qiskit/converter.py +++ b/pennylane_qiskit/converter.py @@ -17,10 +17,13 @@ """ from typing import Dict, Any import warnings +from functools import partial, reduce import numpy as np from qiskit import QuantumCircuit -from qiskit.circuit import Parameter, ParameterExpression, ParameterVector, Measure, Barrier +from qiskit.circuit import Parameter, ParameterExpression, ParameterVector +from qiskit.circuit import Measure, Barrier, ControlFlowOp +from qiskit.circuit.controlflow.switch_case import _DefaultCaseType from qiskit.circuit.library import GlobalPhaseGate from qiskit.exceptions import QiskitError from sympy import lambdify @@ -54,6 +57,31 @@ def _check_parameter_bound(param: Parameter, unbound_params: Dict[Parameter, Any raise ValueError(f"The parameter {param} was not bound correctly.".format(param)) +def _process_basic_param_args(params, *args, **kwargs): + """Process the basic conditions for parameter dictionary computation. + + Returns: + params (dict): A dictionary mapping ``quantum_circuit.parameters`` to values + flag (bool): Indicating whether the returned ``params`` can be used. + """ + + # if no kwargs are passed, and a dictionary has been passed as a single argument, then assume it is params + if params is None and not kwargs and (len(args) == 1 and isinstance(args[0], dict)): + return (args[0], True) + + if not args and not kwargs: + return (params, True) + + # make params dict if using args and/or kwargs + if params is not None: + raise RuntimeError( + "Cannot define parameters via the params kwarg when passing Parameter values " + "as individual args or kwargs." + ) + + return ({}, False) + + def _expected_parameters(quantum_circuit): """Gets the expected parameters and a string of their names from the QuantumCircuit. Primarily serves to change a list of Parameters and ParameterVectorElements into a list @@ -104,22 +132,12 @@ def _format_params_dict(quantum_circuit, params, *args, **kwargs): params (dict): A dictionary mapping ``quantum_circuit.parameters`` to values """ - # if no kwargs are passed, and a dictionary has been passed as a single argument, then assume it is params - if params is None and not kwargs and (len(args) == 1 and isinstance(args[0], dict)): - return args[0] + params, flag = _process_basic_param_args(params, *args, **kwargs) - if not args and not kwargs: + if flag: return params - # make params dict if using args and/or kwargs - if params is not None: - raise RuntimeError( - "Cannot define parameters via the params kwarg when passing Parameter values " - "as individual args or kwargs." - ) - expected_params, param_name_string = _expected_parameters(quantum_circuit) - params = {} # populate it with any parameters defined as kwargs for k, v in kwargs.items(): @@ -228,6 +246,40 @@ def _check_circuit_and_assign_parameters( return quantum_circuit.assign_parameters(params) +def _get_operation_params(instruction, unbound_params) -> list: + """Extract the bound parameters from the operation. + + If the bound parameters are a Qiskit ParameterExpression, then replace it with + the corresponding PennyLane variable from the unbound_params dictionary. + + Args: + instruction (qiskit.circuit.Instruction): a qiskit's quantum circuit instruction + unbound_params dict[qiskit.circuit.Parameter, Any]: a dictionary mapping + qiskit parameters to trainable parameter values + + Returns: + list: bound parameters of the given instruction + """ + operation_params = [] + for p in instruction.params: + _check_parameter_bound(p, unbound_params) + + if isinstance(p, ParameterExpression): + if p.parameters: # non-empty set = has unbound parameters + ordered_params = tuple(p.parameters) + f = lambdify(ordered_params, getattr(p, "_symbol_expr"), modules=qml.numpy) + f_args = [] + for i_ordered_params in ordered_params: + f_args.append(unbound_params.get(i_ordered_params)) + operation_params.append(f(*f_args)) + else: # needed for qiskit<0.43.1 + operation_params.append(float(p)) # pragma: no cover + else: + operation_params.append(p) + + return operation_params + + def map_wires(qc_wires: list, wires: list) -> dict: """Utility function mapping the wires specified in a quantum circuit with the wires specified by the user for the template. @@ -251,24 +303,7 @@ def map_wires(qc_wires: list, wires: list) -> dict: ) -def execute_supported_operation(operation_name: str, parameters: list, wires: list): - """Utility function that executes an operation that is natively supported by PennyLane. - - Args: - operation_name (str): Name of the PL operator to be executed - parameters (str): parameters of the operation that will be executed - wires (list): wires of the operation - """ - operation = getattr(pennylane_ops, operation_name) - - if not parameters: - operation(wires=wires) - elif operation_name in ["QubitStateVector", "StatePrep"]: - operation(np.array(parameters), wires=wires) - else: - operation(*parameters, wires=wires) - - +# pylint:disable=too-many-statements, too-many-branches def load(quantum_circuit: QuantumCircuit, measurements=None): """Loads a PennyLane template from a Qiskit QuantumCircuit. Warnings are created for each of the QuantumCircuit instructions that were @@ -284,7 +319,7 @@ def load(quantum_circuit: QuantumCircuit, measurements=None): function: the resulting PennyLane template """ - # pylint:disable=too-many-branches + # pylint:disable=too-many-branches, fixme, protected-access def _function(*args, params: dict = None, wires: list = None, **kwargs): """Returns a PennyLane quantum function created based on the input QuantumCircuit. Warnings are created for each of the QuantumCircuit instructions that were @@ -355,7 +390,8 @@ def _function(*args, params: dict = None, wires: list = None, **kwargs): """ - # organize parameters, format trainable parameter values correctly, and then bind the parameters to the circuit + # organize parameters, format trainable parameter values correctly, + # and then bind the parameters to the circuit params = _format_params_dict(quantum_circuit, params, *args, **kwargs) unbound_params = _extract_variable_refs(params) qc = _check_circuit_and_assign_parameters(quantum_circuit, params, unbound_params) @@ -366,56 +402,46 @@ def _function(*args, params: dict = None, wires: list = None, **kwargs): wire_map = map_wires(qc_wires, wires) # Stores the measurements encountered in the circuit - mid_circ_meas, terminal_meas = [], [] + # terminal_meas / mid_circ_meas -> terminal / mid-circuit measurements + # mid_circ_regs -> maps the classical registers to the measurements done + terminal_meas, mid_circ_meas = [], [] + mid_circ_regs = {} # Processing the dictionary of parameters passed - for idx, (op, qargs, _) in enumerate(qc.data): - # the new Singleton classes have different names than the objects they represent, but base_class.__name__ still matches - instruction_name = getattr(op, "base_class", op.__class__).__name__ - - operation_wires = [wire_map[hash(qubit)] for qubit in qargs] - + for idx, circuit_instruction in enumerate(qc.data): + (instruction, qargs, cargs) = circuit_instruction + # the new Singleton classes have different names than the objects they represent, + # but base_class.__name__ still matches + instruction_name = getattr(instruction, "base_class", instruction.__class__).__name__ # New Qiskit gates that are not natively supported by PL (identical # gates exist with a different name) # TODO: remove the following when gates have been renamed in PennyLane instruction_name = "U3Gate" if instruction_name == "UGate" else instruction_name - # pylint:disable=protected-access - if ( - instruction_name in inv_map - and inv_map[instruction_name] in pennylane_ops._qubit__ops__ - ): - # Extract the bound parameters from the operation. If the bound parameters are a - # Qiskit ParameterExpression, then replace it with the corresponding PennyLane - # variable from the unbound_params dictionary. - - pl_parameters = [] - for p in op.params: - _check_parameter_bound(p, unbound_params) - - if isinstance(p, ParameterExpression): - if p.parameters: # non-empty set = has unbound parameters - ordered_params = tuple(p.parameters) - - f = lambdify(ordered_params, p._symbol_expr, modules=qml.numpy) - f_args = [] - for i_ordered_params in ordered_params: - f_args.append(unbound_params.get(i_ordered_params)) - pl_parameters.append(f(*f_args)) - else: # needed for qiskit<0.43.1 - pl_parameters.append(float(p)) # pragma: no cover - else: - pl_parameters.append(p) - - execute_supported_operation( - inv_map[instruction_name], pl_parameters, operation_wires - ) + # Define operator builders and helpers + # operation_class -> PennyLane operation class object mapped from the Qiskit operation + # operation_args and operation_kwargs -> Parameters required for the + # instantiation of `operation_class` + operation_class = None + operation_wires = [wire_map[hash(qubit)] for qubit in qargs] + operation_kwargs = {"wires": operation_wires} + operation_args = [] + + # Extract the bound parameters from the operation. If the bound parameters are a + # Qiskit ParameterExpression, then replace it with the corresponding PennyLane + # variable from the unbound_params dictionary. + operation_params = _get_operation_params(instruction, unbound_params) + + if instruction_name in dagger_map: + operation_class = qml.adjoint(dagger_map[instruction_name]) - elif instruction_name in dagger_map: - gate = dagger_map[instruction_name] - qml.adjoint(gate)(wires=operation_wires) + elif instruction_name in inv_map: + operation_class = getattr(pennylane_ops, inv_map[instruction_name]) + operation_args.extend(operation_params) + if operation_class in (qml.QubitStateVector, qml.StatePrep): + operation_args = [np.array(operation_params)] - elif isinstance(op, Measure): + elif isinstance(instruction, Measure): # Store the current operation wires op_wires = set(operation_wires) # Look-ahead for more gate(s) on its wire(s) @@ -429,22 +455,82 @@ def _function(*args, params: dict = None, wires: list = None, **kwargs): meas_terminal = False break + # Allows for adding terminal measurements + if meas_terminal: + terminal_meas.extend(operation_wires) + # Allows for queing the mid-circuit measurements - if not meas_terminal: - mid_circ_meas.append(qml.measure(wires=operation_wires)) else: - terminal_meas.extend(operation_wires) + operation_class = qml.measure + mid_circ_meas.append(qml.measure(wires=operation_wires)) + + # Allows for tracking conditional operations + for carg in cargs: + mid_circ_regs[carg] = mid_circ_meas[-1] else: + try: - operation_matrix = op.to_matrix() - pennylane_ops.QubitUnitary(operation_matrix, wires=operation_wires) + if not isinstance(instruction, (ControlFlowOp,)): + operation_args = [instruction.to_matrix()] + operation_class = qml.QubitUnitary + except (AttributeError, QiskitError): warnings.warn( f"{__name__}: The {instruction_name} instruction is not supported by PennyLane," " and has not been added to the template.", UserWarning, ) + + # Check if it is a conditional operation or conditional instruction + instruction_cond = instruction.condition and instruction.condition[0] in mid_circ_regs + if instruction_cond or isinstance(instruction, ControlFlowOp): + # Iteratively recurse over to build different branches + with qml.QueuingManager.stop_recording(): + branch_funcs = [ + partial(load(branch_inst, measurements=None), params=params, wires=wires) + for branch_inst in operation_params + if isinstance(branch_inst, QuantumCircuit) + ] + + # Get the functions for handling condition + true_fn, false_fn, elif_fns, cond_op = _conditional_funcs( + instruction, cargs, operation_class, branch_funcs, instruction_name + ) + res_reg, res_bit = cond_op + + # Check for elif branches (doesn't require qjit) + if elif_fns: + m_val = sum(2**idx * mid_circ_regs[clbit] for idx, clbit in enumerate(res_reg)) + for elif_bit, elif_branch in elif_fns: + qml.cond(m_val == elif_bit, elif_branch)( + *operation_args, **operation_kwargs + ) + + # Check if just conditional requires some extra work + if isinstance(res_bit, str): + # Handles the default case in the SwitchCaseOp + if res_bit == "SwitchDefault": + elif_bits = [elif_bit for (elif_bit, _) in elif_fns] + qml.cond( + reduce( + lambda m0, m1: m0 & m1, + [(m_val != elif_bit) for elif_bit in elif_bits], + ), + true_fn, + )(*operation_args, **operation_kwargs) + # Just do the routine conditional + else: + qml.cond( + mid_circ_regs[res_reg] == res_bit, + true_fn, + false_fn, + )(*operation_args, **operation_kwargs) + + # Check if it is not a mid-circuit measurement + elif operation_class and not isinstance(instruction, Measure): + operation_class(*operation_args, **operation_kwargs) + # Use the user-provided measurements if measurements: if qml.queuing.QueuingManager.active_context(): @@ -474,3 +560,38 @@ def load_qasm_from_file(file: str): function: the new PennyLane template """ return load(QuantumCircuit.from_qasm_file(file)) + + +# pylint:disable=fixme, protected-access +def _conditional_funcs(ops, cargs, operation_class, branch_funcs, ctrl_flow_type): + """Builds the conditional functions for Controlled flows + + This method returns the arguments to be used by the `qml.cond` + for creating a classically controlled flow. + These are the branches (`true_fn`, `false_fn`, `elif_fns`) and + the qiskit's classical condition, which has to be converted to + the corresponding PennyLane mid-circuit measurement. + """ + true_fn, false_fn, elif_fns = operation_class, None, () + # Logic for using legacy c_if + if not isinstance(ops, ControlFlowOp): + return true_fn, false_fn, elif_fns, ops.condition + + # Logic for handling IfElseOp + if ctrl_flow_type == "IfElseOp": + true_fn = branch_funcs[0] + if len(branch_funcs) == 2: + false_fn = branch_funcs[1] + + # Logic for handling SwitchCaseOp + elif ctrl_flow_type == "SwitchCaseOp": + elif_fns = [] + for case, res_bit in ops._case_map.items(): + if not isinstance(case, _DefaultCaseType): + elif_fns.append((case, branch_funcs[res_bit])) + ops.condition = [tuple(cargs), "SwitchCase"] + if any((isinstance(case, _DefaultCaseType) for case in ops._case_map)): + true_fn = branch_funcs[-1] + ops.condition = [tuple(cargs), "SwitchDefault"] + + return true_fn, false_fn, elif_fns, ops.condition diff --git a/tests/test_converter.py b/tests/test_converter.py index 0efe2b2d2..45b4cbacb 100644 --- a/tests/test_converter.py +++ b/tests/test_converter.py @@ -5,10 +5,9 @@ from qiskit import ClassicalRegister, QuantumCircuit, QuantumRegister from qiskit.circuit import library as lib from qiskit.circuit import Parameter, ParameterVector -from qiskit.circuit.library import EfficientSU2 from qiskit.exceptions import QiskitError from qiskit.quantum_info.operators import Operator - +from qiskit.circuit.library import DraperQFTAdder import pennylane as qml from pennylane import numpy as np from pennylane_qiskit.converter import ( @@ -812,13 +811,14 @@ class TestConverterWarningsAndErrors: def test_template_not_supported(self, recorder): """Tests that a warning is raised if an unsupported instruction was reached.""" - qc = EfficientSU2(3, reps=1) + qc = DraperQFTAdder(3) quantum_circuit = load(qc) - params = np.arange(12) + params = [] + with pytest.warns(UserWarning) as record: with recorder: - quantum_circuit(params) + quantum_circuit(*params) # check that the message matches assert ( @@ -1334,6 +1334,7 @@ def test_meas_circuit_in_qnode(self, qubit_device_2_wires): qc = QuantumCircuit(2, 2) qc.h(0) qc.measure(0, 0) + qc.z(0).c_if(0, 1) qc.rz(angle, [0]) qc.cx(0, 1) qc.measure_all() @@ -1348,7 +1349,8 @@ def circuit_loaded_qiskit_circuit(): @qml.qnode(qubit_device_2_wires) def circuit_native_pennylane(): qml.Hadamard(0) - qml.measure(0) + m0 = qml.measure(0) + qml.cond(m0, qml.PauliZ)(0) qml.RZ(angle, wires=0) qml.CNOT([0, 1]) return [qml.expval(qml.PauliZ(0)), qml.vn_entropy([1])] @@ -1397,6 +1399,127 @@ def test_diff_meas_circuit(self): qtemp2 = load(qc, measurements=[qml.expval(qml.PauliZ(0))]) assert qtemp()[0] != qtemp2()[0] and qtemp2()[0] == qml.expval(qml.PauliZ(0)) + def test_control_flow_ops_circuit_ifelse(self): + """Tests mid-measurements are recognized and returned correctly.""" + + qc = QuantumCircuit(3, 3) + qc.h(0) + qc.cx(0, 1) + qc.measure(0, 0) + qc.h(0) + qc.cx(0, 1) + qc.measure(0, 1) + + with qc.if_test((0, 0)) as else_: + qc.x(0) + + with else_: + qc.h(0) + qc.z(2) + + qc.rz(0.24, [0]) + qc.cx(0, 1) + qc.measure_all() + + dev = qml.device("default.qubit", wires=3) + + @qml.qnode(dev) + def loaded_qiskit_circuit(): + meas = load(qc)() + return [qml.expval(m) for m in meas] + + @qml.qnode(dev) + def built_pl_circuit(): + + qml.Hadamard(0) + qml.CNOT([0, 1]) + m0 = qml.measure(0) + qml.Hadamard(0) + qml.CNOT([0, 1]) + m1 = qml.measure(0) + + def ansatz_true(): + qml.PauliX(wires=0) + + def ansatz_false(): + qml.Hadamard(wires=0) + qml.PauliZ(wires=2) + + qml.cond(m0 == 0, ansatz_true, ansatz_false)() + + qml.RZ(0.24, wires=0) + qml.CNOT([0, 1]) + + return [qml.expval(m) for m in [m0, m1, qml.measure(0), qml.measure(1), qml.measure(2)]] + + assert loaded_qiskit_circuit() == built_pl_circuit() + + assert all( + ( + op1 == op2 + if not isinstance(op1, qml.measurements.MidMeasureMP) + else op1.wires == op2.wires + ) + for op1, op2 in zip( + loaded_qiskit_circuit.tape.operations, built_pl_circuit.tape.operations + ) + ) + + def test_control_flow_ops_circuit_switch(self): + """Tests mid-measurements are recognized and returned correctly.""" + + qreg = QuantumRegister(3) + creg = ClassicalRegister(3) + qc = QuantumCircuit(qreg, creg) + qc.rx(0.12, 0) + qc.rx(0.24, 1) + qc.rx(0.36, 2) + qc.measure([0, 1, 2], [0, 1, 2]) + + with qc.switch(creg) as case: + with case(0): + qc.x(0) + with case(1, 2): + qc.x(1) + with case(case.DEFAULT): + qc.x(2) + qc.measure_all() + + dev = qml.device("default.qubit", wires=3, seed=24) + measurements = [qml.expval(qml.PauliZ(0))] + + @qml.qnode(dev) + def loaded_qiskit_circuit(): + return load(qc, measurements=measurements)() + + @qml.qnode(dev) + def built_pl_circuit(): + qml.RX(0.12, 0) + qml.RX(0.24, 1) + qml.RX(0.36, 2) + m0 = qml.measure(0) + m1 = qml.measure(1) + m2 = qml.measure(2) + m3 = m0 + 2 * m1 + 4 * m2 + qml.cond(m3 == 0, qml.PauliX)(0) + qml.cond(m3 == 1, qml.PauliX)(1) + qml.cond(m3 == 2, qml.PauliX)(1) + qml.cond((m3 != 0) & (m3 != 1) & (m3 != 2), qml.PauliX)(2) + + return [qml.expval(qml.PauliZ(0))] + + assert loaded_qiskit_circuit() == built_pl_circuit() + assert all( + ( + op1 == op2 + if not isinstance(op1, qml.measurements.MidMeasureMP) + else op1.wires == op2.wires + ) + for op1, op2 in zip( + loaded_qiskit_circuit.tape.operations, built_pl_circuit.tape.operations + ) + ) + def test_direct_qnode_ui(self): """Test the UI where the loaded function is passed directly to qml.QNode along with a device""" @@ -1549,4 +1672,3 @@ def circuit_loaded_qiskit_circuit(): return qml.expval(qml.PauliZ(0)) assert circuit_loaded_qiskit_circuit() == circuit_native_pennylane() - diff --git a/tests/test_new_qiskit_temp.py b/tests/test_new_qiskit_temp.py index 31451defd..3210ba462 100644 --- a/tests/test_new_qiskit_temp.py +++ b/tests/test_new_qiskit_temp.py @@ -32,4 +32,3 @@ def test_error_is_raised_if_initalizing_device(monkeypatch, device_name): else: # use a Mock backend to avoid call to the remote service qml.device(device_name, wires=2, backend=Mock()) -