How to modify Pennylane to use Qiskit UI?

Greetings there,

Hope all are well. So, I am creating a library where I wrap existing circuit frameworks. The UI I use is very similar to Qiskit:

circuit = Circuit(2)
circuit.H(0)
circuit.CX(0, 1)

So, here a Circuit instance is first created, and all gates are added to this circuit instance. Now, the natural difficulty I have with wrapping Pennylane is that Pennylane represents circuit instances using decorated functions, like so

@qml.qnode(dev)
def circuit():
    qml.Hadamard(wires=0)
    qml.CNOT(wires=[0, 1])
    return qml.expval(qml.PauliZ(wires=[0, 1]))

So, a few issues from my standpoint:

  1. This UI requires knowing all operations in advance to create the circuit, and if one attempts to store the ops, and then “update” the circuit instance, they’d have to recreate the circuit from scratch every time using a new decorated function with qml.apply(op).
  2. This UI does not allow for creating just the circuit. It also requires some return type as specified in the documentation for QNodes.

This is my current understanding. I could be wrong, so please correct me if so.

So, I want a way to have a UI similar to Qiskit (or what I described above, which is also how Cirq and TKET do the circuit creation). Please let me know if any part of what I explained is unclear/confusing and I will do my best to remedy it.

Here’s my current attempt (which works, but admittedly I hate it given it need to recreate the circuit every time for use).

""" Wrapper class for using Xanadu's PennyLane in Qickit SDK.
"""

from __future__ import annotations

__all__ = ["PennylaneCircuit"]

from collections.abc import Sequence
import numpy as np
from numpy.typing import NDArray
from typing import Callable, TYPE_CHECKING

import pennylane as qml # type: ignore

if TYPE_CHECKING:
    from qickit.backend import Backend
from qickit.circuit import Circuit
from qickit.circuit.circuit import GATES


class PennylaneCircuit(Circuit):
    """ `qickit.circuit.PennylaneCircuit` is the wrapper for using Xanadu's PennyLane in Qickit SDK.

    Notes
    -----
    Xanadu's PennyLane is a cross-platform Python library for quantum computing,
    quantum machine learning, and quantum chemistry.

    For more information on PennyLane:
    - Documentation:
    https://docs.pennylane.ai/en/stable/code/qml.html
    - Source Code:
    https://github.com/PennyLaneAI/pennylane
    - Publication:
    https://arxiv.org/pdf/1811.04968

    Parameters
    ----------
    `num_qubits` : int
        Number of qubits in the circuit.

    Attributes
    ----------
    `num_qubits` : int
        Number of qubits in the circuit.
    `circuit` : list[qml.Operation]
        The circuit.
    `gate_mapping` : dict[str, Callable]
        The mapping of the gates in the input quantum computing
        framework to the gates in qickit.
    `device` : qml.Device
        The PennyLane device to use.
    `measured_qubits` : set[int]
        The set of measured qubits indices.
    `circuit_log` : list[dict]
        The circuit log.
    `global_phase` : float
        The global phase of the circuit.
    `process_gate_params_flag` : bool
        The flag to process the gate parameters.

    Raises
    ------
    TypeError
        - Number of qubits bits must be integers.
    ValueError
        - Number of qubits bits must be greater than 0.

    Usage
    -----
    >>> circuit = PennylaneCircuit(num_qubits=2)
    """
    def __init__(
            self,
            num_qubits: int
        ) -> None:

        super().__init__(num_qubits=num_qubits)

        self.device = qml.device("default.qubit", wires=self.num_qubits)
        self.circuit: list[qml.Operation] = []

    @staticmethod
    def _define_gate_mapping() -> dict[str, Callable]:
        # Define lambda factory for non-parameterized gates
        def const(x):
            return lambda _angles: x

        gate_mapping = {
            "I": const(qml.Identity(0).matrix()),
            "X": const(qml.PauliX(0).matrix()),
            "Y": const(qml.PauliY(0).matrix()),
            "Z": const(qml.PauliZ(0).matrix()),
            "H": const(qml.Hadamard(wires=0).matrix()),
            "S": const(qml.S(wires=0).matrix()),
            "Sdg": const(qml.adjoint(qml.S(0)).matrix()), # type: ignore
            "T": const(qml.T(wires=0).matrix()),
            "Tdg": const(qml.adjoint(qml.T(0)).matrix()), # type: ignore
            "RX": lambda angles: qml.RX(phi=angles[0], wires=0).matrix(), # type: ignore
            "RY": lambda angles: qml.RY(phi=angles[0], wires=0).matrix(), # type: ignore
            "RZ": lambda angles: qml.RZ(phi=angles[0], wires=0).matrix(), # type: ignore
            "Phase": lambda angles: qml.PhaseShift(phi=angles[0], wires=0).matrix(), # type: ignore
            "U3": lambda angles: qml.U3(theta=angles[0], phi=angles[1], delta=angles[2], wires=0).matrix() # type: ignore
        }

        return gate_mapping

    def _gate_mapping(
            self,
            gate: GATES,
            target_indices: int | Sequence[int],
            control_indices: int | Sequence[int] = [],
            angles: Sequence[float] = [0, 0, 0]
        ) -> None:

        target_indices = [target_indices] if isinstance(target_indices, int) else target_indices
        control_indices = [control_indices] if isinstance(control_indices, int) else control_indices

        # Lazily extract the value of the gate from the mapping to avoid
        # creating all the gates at once, and to maintain the abstraction
        # Apply the gate operation to the specified qubits
        if "MC" in gate:
            for target_index in target_indices:
                self.circuit.append(
                qml.ControlledQubitUnitary(
                    self.gate_mapping[gate.removeprefix("MC")](angles),
                    control_wires=control_indices,
                    wires=target_index
                )
            )
            return

        for target_index in target_indices:
            self.circuit.append(
                qml.QubitUnitary(self.gate_mapping[gate](angles), wires=target_index)
            )

    def GlobalPhase(
            self,
            angle: float
        ) -> None:

        self.process_gate_params(gate=self.GlobalPhase.__name__, params=locals())

        # Create a Global Phase gate
        global_phase = qml.GlobalPhase
        self.circuit.append(global_phase(-angle))
        self.global_phase += angle

    def measure(
            self,
            qubit_indices: int | Sequence[int]
        ) -> None:

        self.process_gate_params(gate=self.measure.__name__, params=locals())

        # In PennyLane, we apply measurements in '.get_statevector', and '.get_counts'
        # methods
        # This is due to the need for PennyLane quantum functions to return measurement results
        # Therefore, we do not need to do anything here
        if isinstance(qubit_indices, int):
            qubit_indices = [qubit_indices]

        # Set the measurement as applied
        for qubit_index in qubit_indices:
            self.measured_qubits.add(qubit_index)
            self.circuit.append((qml.measure(qubit_index), False)) # type: ignore

    def get_statevector(
            self,
            backend: Backend | None = None,
        ) -> NDArray[np.complex128]:

        # Copy the circuit as the operations are applied inplace
        circuit: PennylaneCircuit = self.copy() # type: ignore

        # PennyLane uses MSB convention for qubits, so we need to reverse the qubit indices
        circuit.vertical_reverse()

        def compile_circuit() -> qml.StateMP:
            """ Compile the circuit.

            Parameters
            ----------
            circuit : Collection[qml.Op]
                The list of operations representing the circuit.

            Returns
            -------
            qml.StateMP
                The state vector of the circuit.
            """
            # Apply the operations in the circuit
            for op in circuit.circuit:
                if isinstance(op, tuple):
                    qml.measure(op[0].wires[0], reset=op[1]) # type: ignore
                    continue

                qml.apply(op)

            return qml.state()

        if backend is None:
            state_vector = qml.QNode(compile_circuit, circuit.device)()
        else:
            state_vector = backend.get_statevector(circuit)

        return np.array(state_vector)

    def get_counts(
            self,
            num_shots: int,
            backend: Backend | None = None
        ) -> dict[str, int]:

        np.random.seed(0)

        if len(self.measured_qubits) == 0:
            raise ValueError("At least one qubit must be measured.")

        # Copy the circuit as the operations are applied inplace
        circuit: PennylaneCircuit = self.copy() # type: ignore

        # PennyLane uses MSB convention for qubits, so we need to reverse the qubit indices
        circuit.vertical_reverse()

        def compile_circuit() -> qml.CountsMp:
            """ Compile the circuit.

            Parameters
            ----------
            circuit : Collection[qml.Op]
                The list of operations representing the circuit.

            Returns
            -------
            Collection[qml.ProbabilityMP]
                The list of probability measurements.
            """
            # Apply the operations in the circuit
            for op in circuit.circuit:
                if isinstance(op, tuple):
                    qml.measure(op[0].wires[0], reset=op[1]) # type: ignore
                    continue

                qml.apply(op)

            return qml.counts(wires=circuit.measured_qubits, all_outcomes=True)

        if backend is None:
            device = qml.device(circuit.device.name, wires=circuit.num_qubits, shots=num_shots)
            result = qml.QNode(compile_circuit, device)()
            counts = {list(result.keys())[i]: int(list(result.values())[i]) for i in range(len(result))}
        else:
            result = backend.get_counts(self, num_shots=num_shots)

        return counts

    def get_depth(self) -> int:
        circuit = self.convert(QiskitCircuit)
        return circuit.get_depth()

    def get_unitary(self) -> NDArray[np.complex128]:
        # Copy the circuit as the operations are applied inplace
        circuit: PennylaneCircuit = self.copy() # type: ignore

        # PennyLane uses MSB convention for qubits, so we need to reverse the qubit indices
        circuit.vertical_reverse()

        def compile_circuit() -> None:
            """ Compile the circuit.

            Parameters
            ----------
            `circuit` : Collection[qml.Op]
                The list of operations representing the circuit.
            """
            if circuit.circuit == [] or (
                isinstance(circuit.circuit[0], qml.GlobalPhase) and len(circuit.circuit) == 1
            ):
                for i in range(circuit.num_qubits):
                    circuit.circuit.append(qml.Identity(wires=i))

            # Apply the operations in the circuit
            for op in circuit.circuit:
                if isinstance(op, tuple):
                    qml.measure(op[0].wires[0], reset=op[1]) # type: ignore
                    continue

                qml.apply(op)

        # Run the circuit and define the unitary matrix
        unitary = np.array(qml.matrix(compile_circuit, wire_order=range(self.num_qubits))(), dtype=complex) # type: ignore

        return unitary

    def reset_qubit(
            self,
            qubit_indices: int | Sequence[int]
        ) -> None:

        self.process_gate_params(gate=self.reset_qubit.__name__, params=locals())

        if isinstance(qubit_indices, int):
            qubit_indices = [qubit_indices]

        for qubit_index in qubit_indices:
            self.circuit.append((qml.measure(qubit_index), True)) # type: ignore

Hi @ACE8009 , welcome to the Forum!

I’m not completely sure how your UI works but here are some tips that could help you:

  1. You can create the QNode with qml.QNode() instead of the decorator. See an example here in the docs.
  2. You can use quantum functions that you nest within each other before you turn them into a QNode. For example you can have the following code, where you don’t need to have all gates as part of the same function.
# Import PennyLane
import pennylane as qml

# Create any subfunctions you need
def my_subfunction():
  qml.Hadamard(wires=0)

# Create a quantum function that returns a measurement
def q_func(params):
  qml.RY(params,wires=0)
  my_subfunction()
  qml.CNOT(wires=[0,1])
  return qml.expval(qml.PauliZ(0)),qml.expval(qml.PauliZ(1))

# Set an initial value for your parameters in order to run your circuit
my_params = 1.1

# Draw the quantum function 
qml.draw_mpl(q_func)(my_params)

# Create a QNode
dev = qml.device('default.qubit',wires=2)
my_qnode = qml.QNode(q_func,dev)

# Print the output of your circuit (you can draw the QNode too)
my_qnode(my_params)

Does this answer your questions or concerns? Let us know if you have any additional questions.

Greetings Miss. Albornoz,

Hope you are well. Yeah, this is what I was mentioning. This requires knowing exactly what gates you want to apply in advance, and you’d have to recreate the q_func everytime you add sth to it.

I’ll see if the nested approach works in the meantime, but what I was looking forward is how to use pennylane like qiskit.

Hi @ACE8009 ,

In that case could you please provide a small example (like the one I shared) of what you would expect to be able to do?

You may be able to use tapes or transforms, or import your circuit from Qiskit directly as shown here. Let me know if this is what you were looking for.