Repository: PennyLaneAI/pennylane
Pull request #5251 (2024-02-22 21:59:18)

Add function to load Qiskit operators
**Context:**

The next release of the PennyLane-Qiskit plugin will include PennyLaneAI/pennylane-qiskit#401 which features a new function for converting Qiskit [SparsePauliOp](https://docs.quantum.ibm.com/api/qiskit/qiskit.quantum_info.SparsePauliOp) instances into PennyLane operators.

**Description of the Change:**

* Added a `from_qiskit_op()` function to the IO module for loading Qiskit operators.

In a nutshell,

```python
>>> import pennylane as qml
>>> from qiskit.quantum_info import SparsePauliOp
>>> qiskit_op = SparsePauliOp(["II", "XY"])
>>> qml.from_qiskit_op(qiskit_op)
I(0) + X(1) @ Y(0)
```

**Benefits:**

* Qiskit operators can be directly converted into PennyLane operators.

**Possible Drawbacks:**

None.

**Related GitHub Issues:**

None.


codecov[bot]: ## [Codecov](https://app.codecov.io/gh/PennyLaneAI/pennylane/pull/5251?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 99.67%. Comparing base [(`af718c6`)](https://app.codecov.io/gh/PennyLaneAI/pennylane/commit/af718c60ae44b6c3ad9af4580943826d97df8e5a?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI) to head [(`feee11a`)](https://app.codecov.io/gh/PennyLaneAI/pennylane/pull/5251?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI).


<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##           master    #5251      +/-   ##
==========================================
- Coverage   99.68%   99.67%   -0.01%     
==========================================
  Files         399      399              
  Lines       36853    36577     -276     
==========================================
- Hits        36736    36459     -277     
- Misses        117      118       +1     
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/PennyLaneAI/pennylane/pull/5251?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI).

Mandrenkov: Thanks for the review, @trbromley!

Patch:
diff --git a/doc/releases/changelog-dev.md b/doc/releases/changelog-dev.md
index 8440d2ed9..70b78a0b7 100644
--- a/doc/releases/changelog-dev.md
+++ b/doc/releases/changelog-dev.md
@@ -10,6 +10,9 @@
   function fails because the Qiskit converter is missing.
   [(#5218)](https://github.com/PennyLaneAI/pennylane/pull/5218)
 
+* A Qiskit `SparsePauliOp` can be converted into a PennyLane `Operator` using `qml.from_qiskit_op`.
+  [(#5251)](https://github.com/PennyLaneAI/pennylane/pull/5251)
+
 <h4>Native mid-circuit measurements on default qubit 💡</h4>
 
 * When operating in finite-shots mode, the `default.qubit` device now performs mid-circuit
diff --git a/pennylane/io.py b/pennylane/io.py
index fb5279ba0..edabae6ee 100644
--- a/pennylane/io.py
+++ b/pennylane/io.py
@@ -19,6 +19,16 @@ from collections import defaultdict
 from importlib import metadata
 from sys import version_info
 
+
+# Error message to show when the PennyLane-Qiskit plugin is required but missing.
+_MISSING_QISKIT_PLUGIN_MESSAGE = (
+    "Conversion from Qiskit requires the PennyLane-Qiskit plugin. "
+    "You can install the plugin by running: pip install pennylane-qiskit. "
+    "You may need to restart your kernel or environment after installation. "
+    "If you have any difficulties, you can reach out on the PennyLane forum at "
+    "https://discuss.pennylane.ai/c/pennylane-plugins/pennylane-qiskit/"
+)
+
 # get list of installed plugin converters
 __plugin_devices = (
     defaultdict(tuple, metadata.entry_points())["pennylane.io"]
@@ -85,8 +95,8 @@ def load(quantum_circuit_object, format: str, **load_kwargs):
 
 
 def from_qiskit(quantum_circuit, measurements=None):
-    """Loads Qiskit QuantumCircuit objects by using the converter in the
-    PennyLane-Qiskit plugin.
+    """Loads Qiskit `QuantumCircuit <https://docs.quantum.ibm.com/api/qiskit/qiskit.circuit.QuantumCircuit>`_
+    objects by using the converter in the PennyLane-Qiskit plugin.
 
     **Example:**
 
@@ -110,7 +120,7 @@ def from_qiskit(quantum_circuit, measurements=None):
             overrides the terminal measurements that may be present in the input circuit.
 
     Returns:
-        function: the PennyLane template created based on the QuantumCircuit object
+        function: the PennyLane template created based on the ``QuantumCircuit`` object
 
     .. details::
         :title: Usage Details
@@ -146,13 +156,102 @@ def from_qiskit(quantum_circuit, measurements=None):
         return load(quantum_circuit, format="qiskit", measurements=measurements)
     except ValueError as e:
         if e.args[0].split(".")[0] == "Converter does not exist":
-            raise RuntimeError(
-                "Conversion from Qiskit requires the PennyLane-Qiskit plugin. "
-                "You can install the plugin by running: pip install pennylane-qiskit. "
-                "You may need to restart your kernel or environment after installation. "
-                "If you have any difficulties, you can reach out on the PennyLane forum at "
-                "https://discuss.pennylane.ai/c/pennylane-plugins/pennylane-qiskit/"
-            ) from e
+            raise RuntimeError(_MISSING_QISKIT_PLUGIN_MESSAGE) from e
+        raise e
+
+
+def from_qiskit_op(qiskit_op, params=None, wires=None):
+    """Loads Qiskit `SparsePauliOp <https://docs.quantum.ibm.com/api/qiskit/qiskit.quantum_info.SparsePauliOp>`_
+    objects by using the converter in the PennyLane-Qiskit plugin.
+
+    Args:
+        qiskit_op (qiskit.quantum_info.SparsePauliOp): the ``SparsePauliOp`` to be converted
+        params (Any): optional assignment of coefficient values for the ``SparsePauliOp``; see the
+            `Qiskit documentation <https://docs.quantum.ibm.com/api/qiskit/qiskit.quantum_info.SparsePauliOp#assign_parameters>`_
+            to learn more about the expected format of these parameters
+        wires (Sequence | None): optional assignment of wires for the converted ``SparsePauliOp``;
+            if the original ``SparsePauliOp`` acted on :math:`N` qubits, then this must be a
+            sequence of length :math:`N`
+
+    Returns:
+        Operator: The equivalent PennyLane operator.
+
+    .. note::
+
+        The wire ordering convention differs between PennyLane and Qiskit: PennyLane wires are
+        enumerated from left to right, while the Qiskit convention is to enumerate from right to
+        left. This means a ``SparsePauliOp`` term defined by the string ``"XYZ"`` applies ``Z`` on
+        wire 0, ``Y`` on wire 1, and ``X`` on wire 2. For more details, see the
+        `String representation <https://docs.quantum.ibm.com/api/qiskit/qiskit.quantum_info.Pauli>`_
+        section of the Qiskit documentation for the ``Pauli`` class.
+
+    **Example**
+
+    Consider the following script which creates a Qiskit ``SparsePauliOp``:
+
+    .. code-block:: python
+
+        from qiskit.quantum_info import SparsePauliOp
+
+        qiskit_op = SparsePauliOp(["II", "XY"])
+
+    The ``SparsePauliOp`` contains two terms and acts over two qubits:
+
+    >>> qiskit_op
+    SparsePauliOp(['II', 'XY'],
+                  coeffs=[1.+0.j, 1.+0.j])
+
+    To convert the ``SparsePauliOp`` into a PennyLane :class:`Operator`, use:
+
+    >>> import pennylane as qml
+    >>> qml.from_qiskit_op(qiskit_op)
+    I(0) + X(1) @ Y(0)
+
+    .. details::
+        :title: Usage Details
+
+        You can convert a parameterized ``SparsePauliOp`` into a PennyLane operator by assigning
+        literal values to each coefficient parameter. For example, the script
+
+        .. code-block:: python
+
+            import numpy as np
+            from qiskit.circuit import Parameter
+
+            a, b, c = [Parameter(var) for var in "abc"]
+            param_qiskit_op = SparsePauliOp(["II", "XZ", "YX"], coeffs=np.array([a, b, c]))
+
+        defines a ``SparsePauliOp`` with three coefficients (parameters):
+
+        >>> param_qiskit_op
+        SparsePauliOp(['II', 'XZ', 'YX'],
+              coeffs=[ParameterExpression(1.0*a), ParameterExpression(1.0*b),
+         ParameterExpression(1.0*c)])
+
+        The ``SparsePauliOp`` can be converted into a PennyLane operator by calling the conversion
+        function and specifying the value of each parameter using the ``params`` argument:
+
+        >>> qml.from_qiskit_op(param_qiskit_op, params={a: 2, b: 3, c: 4})
+        (
+            (2+0j) * I(0)
+          + (3+0j) * (X(1) @ Z(0))
+          + (4+0j) * (Y(1) @ X(0))
+        )
+
+        Similarly, a custom wire mapping can be applied to a ``SparsePauliOp`` as follows:
+
+        >>> wired_qiskit_op = SparsePauliOp("XYZ")
+        >>> wired_qiskit_op
+        SparsePauliOp(['XYZ'],
+              coeffs=[1.+0.j])
+        >>> qml.from_qiskit_op(wired_qiskit_op, wires=[3, 5, 7])
+        Y(5) @ Z(3) @ X(7)
+    """
+    try:
+        return load(qiskit_op, format="qiskit_op", params=params, wires=wires)
+    except ValueError as e:
+        if e.args[0].split(".")[0] == "Converter does not exist":
+            raise RuntimeError(_MISSING_QISKIT_PLUGIN_MESSAGE) from e
         raise e
 
 


Test patch:
diff --git a/tests/test_io.py b/tests/test_io.py
index 9a81b3f13..095b7be70 100644
--- a/tests/test_io.py
+++ b/tests/test_io.py
@@ -48,7 +48,15 @@ class MockPluginConverter:
         return self.mock_loader.call_args
 
 
-load_entry_points = ["qiskit", "qasm", "qasm_file", "pyquil_program", "quil", "quil_file"]
+load_entry_points = [
+    "pyquil_program",
+    "qasm_file",
+    "qasm",
+    "qiskit_op",
+    "qiskit",
+    "quil_file",
+    "quil",
+]
 
 
 @pytest.fixture(name="mock_plugin_converters")
@@ -71,42 +79,47 @@ class TestLoad:
         ):
             qml.load("Test", format="some_non_existing_format")
 
-    def test_qiskit_not_installed(self, monkeypatch):
-        """Test that a specific error is raised if qml.from_qiskit is called and the qiskit
-        plugin converter isn't found, instead of the generic 'ValueError: Converter does not exist.'
+    @pytest.mark.parametrize(
+        "method, entry_point_name",
+        [(qml.from_qiskit, "qiskit"), (qml.from_qiskit_op, "qiskit_op")],
+    )
+    def test_qiskit_converter_does_not_exist(self, monkeypatch, method, entry_point_name):
+        """Test that a RuntimeError with an appropriate message is raised if a Qiskit convenience
+        method is called but the Qiskit plugin converter is not found.
         """
-
-        # temporarily make a mock_converter_dict with no "qiskit"
+        # Temporarily make a mock_converter_dict without the Qiskit entry point.
         mock_plugin_converter_dict = {
             entry_point: MockPluginConverter(entry_point) for entry_point in load_entry_points
         }
-        del mock_plugin_converter_dict["qiskit"]
+        del mock_plugin_converter_dict[entry_point_name]
         monkeypatch.setattr(qml.io, "plugin_converters", mock_plugin_converter_dict)
 
-        # calling from_qiskit raises the specific RuntimeError rather than the generic ValueError
-        with pytest.raises(
-            RuntimeError,
-            match="Conversion from Qiskit requires the PennyLane-Qiskit plugin. "
-            "You can install the plugin by",
-        ):
-            qml.from_qiskit("Test")
+        # Check that the specific RuntimeError is raised as opposed to a generic ValueError.
+        with pytest.raises(RuntimeError, match=r"Conversion from Qiskit requires..."):
+            method("Test")
 
-        # if load raises some other ValueError instead of the "converter does not exist" error, it is unaffected
-        def mock_load_with_error(*args, **kwargs):
-            raise ValueError("Some other error raised than instead of converter does not exist")
+    @pytest.mark.parametrize(
+        "method, entry_point_name",
+        [(qml.from_qiskit, "qiskit"), (qml.from_qiskit_op, "qiskit_op")],
+    )
+    def test_qiskit_converter_load_fails(self, monkeypatch, method, entry_point_name):
+        """Test that an exception which is raised while calling a Qiskit convenience method (but
+        after the Qiskit plugin converter is found) is propagated correctly.
+        """
+        mock_plugin_converter = MockPluginConverter(entry_point_name)
+        mock_plugin_converter.mock_loader.side_effect = ValueError("Some Other Error")
 
-        monkeypatch.setattr(qml.io, "load", mock_load_with_error)
+        mock_plugin_converter_dict = {entry_point_name: mock_plugin_converter}
+        monkeypatch.setattr(qml.io, "plugin_converters", mock_plugin_converter_dict)
 
-        with pytest.raises(
-            ValueError,
-            match="Some other error raised than instead of converter does not exist",
-        ):
-            qml.from_qiskit("Test")
+        with pytest.raises(ValueError, match=r"Some Other Error"):
+            method("Test")
 
     @pytest.mark.parametrize(
-        "method,entry_point_name",
+        "method, entry_point_name",
         [
             (qml.from_qiskit, "qiskit"),
+            (qml.from_qiskit_op, "qiskit_op"),
             (qml.from_qasm, "qasm"),
             (qml.from_qasm_file, "qasm_file"),
             (qml.from_pyquil, "pyquil_program"),
@@ -115,7 +128,7 @@ class TestLoad:
         ],
     )
     def test_convenience_functions(self, method, entry_point_name, mock_plugin_converters):
-        """Test that the convenience load functions access the correct entrypoint."""
+        """Test that the convenience load functions access the correct entry point."""
 
         method("Test")
 
@@ -130,21 +143,30 @@ class TestLoad:
                 raise RuntimeError(f"The other plugin converter {plugin_converter} was called.")
 
     @pytest.mark.parametrize(
-        "method, entry_point_name",
+        "method, entry_point_name, args, kwargs",
         [
-            (qml.from_qiskit, "qiskit"),
+            (qml.from_qiskit, "qiskit", ("Circuit",), {"measurements": []}),
+            (qml.from_qiskit_op, "qiskit_op", ("Op",), {"params": [1, 2], "wires": [3, 4]}),
         ],
     )
-    def test_convenience_functions_kwargs(self, method, entry_point_name, mock_plugin_converters):
-        """Test that the convenience load functions access the correct entrypoint with keywords."""
-
-        method("Test", measurements=[])
+    def test_convenience_function_arguments(
+        self,
+        method,
+        entry_point_name,
+        mock_plugin_converters,
+        args,
+        kwargs,
+    ):  # pylint: disable=too-many-arguments
+        """Test that the convenience load functions access the correct entry point and forward their
+        arguments correctly.
+        """
+        method(*args, **kwargs)
 
         assert mock_plugin_converters[entry_point_name].called
 
-        args, kwargs = mock_plugin_converters[entry_point_name].call_args
-        assert args == ("Test",)
-        assert kwargs == {"measurements": []}
+        called_args, called_kwargs = mock_plugin_converters[entry_point_name].call_args
+        assert called_args == args
+        assert called_kwargs == kwargs
 
         for plugin_converter in mock_plugin_converters:
             if plugin_converter == entry_point_name: