Repository: PennyLaneAI/pennylane
Pull request #5464 (2024-04-02 23:18:45)

Add algorithmic errors tracking to `qml.specs`
**Context:** Update `qml.specs` to track and combine error

**Description of the Change:** Adds a `_compute_algo_error` method that combines the individual errors of similar types. Further updates `specs` method of  `QuantumScript` to use the prior method for the required computation in a similar fashion to `resources`. 
 
**Benefits:** `specs` will track algorithmic errors. 

**Possible Drawbacks:** N/A

**Related GitHub Issues:** N/A


obliviateandsurrender: [sc-55444]
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/PennyLaneAI/pennylane/pull/5464?dropdown=coverage&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 [(`1a2895d`)](https://app.codecov.io/gh/PennyLaneAI/pennylane/commit/1a2895d2cc912b38e8fd38c473d009c06eeeab13?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI) to head [(`dd62750`)](https://app.codecov.io/gh/PennyLaneAI/pennylane/pull/5464?dropdown=coverage&src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI).
> Report is 1 commits behind head on master.


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


```diff
@@            Coverage Diff             @@
##           master    #5464      +/-   ##
==========================================
- Coverage   99.68%   99.67%   -0.01%     
==========================================
  Files         402      402              
  Lines       37541    37264     -277     
==========================================
- Hits        37421    37143     -278     
- Misses        120      121       +1     
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/PennyLaneAI/pennylane/pull/5464?dropdown=coverage&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).


Patch:
diff --git a/doc/releases/changelog-dev.md b/doc/releases/changelog-dev.md
index 71880d760..ecda32c06 100644
--- a/doc/releases/changelog-dev.md
+++ b/doc/releases/changelog-dev.md
@@ -209,6 +209,9 @@
   [stim](https://github.com/quantumlib/Stim) `v1.13.0`.
   [(#5409)](https://github.com/PennyLaneAI/pennylane/pull/5409)
 
+* `qml.specs` now returns information regarding algorithmic errors for the qnode as well.
+  [(#5464)](https://github.com/PennyLaneAI/pennylane/pull/5464)
+
 * `qml.transforms.hamiltonian_expand` can now handle multi-term observables with a constant offset.
   [(#5414)](https://github.com/PennyLaneAI/pennylane/pull/5414)
 
diff --git a/pennylane/resource/error/__init__.py b/pennylane/resource/error/__init__.py
index fd7580610..8abe9f434 100644
--- a/pennylane/resource/error/__init__.py
+++ b/pennylane/resource/error/__init__.py
@@ -17,4 +17,4 @@ algorithmic error from advanced quantum algorithms.
 """
 
 from .trotter_error import _one_norm_error, _commutator_error
-from .error import AlgorithmicError, ErrorOperation, SpectralNormError
+from .error import AlgorithmicError, ErrorOperation, SpectralNormError, _compute_algo_error
diff --git a/pennylane/resource/error/error.py b/pennylane/resource/error/error.py
index acb7a5bf4..53a5ca39c 100644
--- a/pennylane/resource/error/error.py
+++ b/pennylane/resource/error/error.py
@@ -15,6 +15,7 @@
 Stores classes and logic to define and track algorithmic error in a quantum workflow.
 """
 from abc import ABC, abstractmethod
+from typing import Dict
 
 import pennylane as qml
 from pennylane.operation import Operation, Operator
@@ -142,3 +143,26 @@ class SpectralNormError(AlgorithmicError):
         m1 = qml.matrix(exact_op, wire_order=wire_order)
         m2 = qml.matrix(approximate_op, wire_order=wire_order)
         return qml.math.max(qml.math.svd(m1 - m2, compute_uv=False))
+
+
+def _compute_algo_error(tape) -> Dict[str, AlgorithmicError]:
+    """Given a quantum circuit (tape), this function computes the algorithmic error
+    generated by standard PennyLane operations.
+
+    Args:
+        tape (.QuantumTape): The quantum circuit for which we compute errors
+
+    Returns:
+        dict[str->.AlgorithmicError]: dict with error name and combined error as key-value pair
+    """
+
+    algo_errors = {}
+    for op in tape.operations:
+        if isinstance(op, ErrorOperation):
+            op_error = op.error()
+            error_name = op_error.__class__.__name__
+            algo_error = algo_errors.get(error_name, None)
+            error_value = op_error if algo_error is None else algo_error.combine(op_error)
+            algo_errors[error_name] = error_value
+
+    return algo_errors
diff --git a/pennylane/resource/specs.py b/pennylane/resource/specs.py
index 1112e1b9a..d515ef84d 100644
--- a/pennylane/resource/specs.py
+++ b/pennylane/resource/specs.py
@@ -52,19 +52,23 @@ def specs(qnode, max_expansion=None, expansion_strategy=None):
     .. code-block:: python3
 
         x = np.array([0.1, 0.2])
+        hamiltonian = qml.dot([1.0, 0.5], [qml.X(0), qml.Y(0)])
 
         dev = qml.device('default.qubit', wires=2)
         @qml.qnode(dev, diff_method="parameter-shift", shifts=np.pi / 4)
         def circuit(x, add_ry=True):
             qml.RX(x[0], wires=0)
             qml.CNOT(wires=(0,1))
+            qml.TrotterProduct(hamiltonian, time=1.0, n=4, order=2)
             if add_ry:
                 qml.RY(x[1], wires=1)
+            qml.TrotterProduct(hamiltonian, time=1.0, n=4, order=4)
             return qml.probs(wires=(0,1))
 
     >>> qml.specs(circuit)(x, add_ry=False)
-    {'resources': Resources(num_wires=2, num_gates=2, gate_types=defaultdict(<class 'int'>, {'RX': 1, 'CNOT': 1}),
-    gate_sizes=defaultdict(<class 'int'>, {1: 1, 2: 1}), depth=2, shots=Shots(total_shots=None, shot_vector=())),
+    {'resources': Resources(num_wires=2, num_gates=4, gate_types=defaultdict(<class 'int'>, {'RX': 1, 'CNOT': 1, 'TrotterPro
+    duct': 2}}), gate_sizes=defaultdict(<class 'int'>, {1: 3, 2: 1}), depth=4, shots=Shots(total_shots=None, shot_vector=())),
+    'errors': {'SpectralNormError': SpectralNormError(0.42998560822421455)},
     'num_observables': 1,
     'num_diagonalizing_gates': 0,
     'num_trainable_params': 1,
@@ -74,7 +78,7 @@ def specs(qnode, max_expansion=None, expansion_strategy=None):
     'gradient_options': {'shifts': 0.7853981633974483},
     'interface': 'auto',
     'diff_method': 'parameter-shift',
-    'gradient_fn': 'pennylane.transforms.core.transform_dispatcher.param_shift',
+    'gradient_fn': 'pennylane.gradients.parameter_shift.param_shift',
     'num_gradient_executions': 2}
 
     """
@@ -87,6 +91,7 @@ def specs(qnode, max_expansion=None, expansion_strategy=None):
             * ``"num_observables"`` number of observables in the qnode
             * ``"num_diagonalizing_gates"`` number of diagonalizing gates required for execution of the qnode
             * ``"resources"``: a :class:`~.resource.Resources` object containing resource quantities used by the qnode
+            * ``"errors"``: combined algorithmic errors from the quantum operations executed by the qnode
             * ``"num_used_wires"``: number of wires used by the circuit
             * ``"num_device_wires"``: number of wires in device
             * ``"depth"``: longest path in directed acyclic graph representation
diff --git a/pennylane/tape/qscript.py b/pennylane/tape/qscript.py
index f450bf016..3a681d2f6 100644
--- a/pennylane/tape/qscript.py
+++ b/pennylane/tape/qscript.py
@@ -979,13 +979,14 @@ class QuantumScript:
         gate_sizes:
         {1: 4, 2: 2}
         """
+        # pylint: disable=protected-access
         if self._specs is None:
-            resources = qml.resource.resource._count_resources(
-                self
-            )  # pylint: disable=protected-access
+            resources = qml.resource.resource._count_resources(self)
+            algo_errors = qml.resource.error._compute_algo_error(self)
 
             self._specs = {
                 "resources": resources,
+                "errors": algo_errors,
                 "num_observables": len(self.observables),
                 "num_diagonalizing_gates": len(self.diagonalizing_gates),
                 "num_trainable_params": self.num_params,


Test patch:
diff --git a/tests/resource/test_error/test_error.py b/tests/resource/test_error/test_error.py
index 8e07e0c1c..d1b171bab 100644
--- a/tests/resource/test_error/test_error.py
+++ b/tests/resource/test_error/test_error.py
@@ -20,7 +20,12 @@ import pytest
 import numpy as np
 
 import pennylane as qml
-from pennylane.resource.error import AlgorithmicError, SpectralNormError, ErrorOperation
+from pennylane.resource.error import (
+    AlgorithmicError,
+    SpectralNormError,
+    ErrorOperation,
+    _compute_algo_error,
+)
 from pennylane.operation import Operation
 
 
@@ -181,3 +186,99 @@ class TestErrorOperation:
                 num_wires = 3
 
             _ = NoErrorOp(wires=[1, 2, 3])
+
+
+class MultiplicativeError(AlgorithmicError):
+    """Multiplicative error object"""
+
+    def combine(self, other):
+        return self.__class__(self.error * other.error)
+
+    def __repr__(self):
+        """Return formal string representation."""
+        return f"MultiplicativeError({self.error})"
+
+
+class AdditiveError(AlgorithmicError):
+    """Additive error object"""
+
+    def combine(self, other):
+        return self.__class__(self.error + other.error)
+
+    def __repr__(self):
+        """Return formal string representation."""
+        return f"AdditiveError({self.error})"
+
+
+class CustomErrorOp1(ErrorOperation):
+    """Custome error operation with multiplicative error"""
+
+    def __init__(self, phase, wires):
+        self.phase = phase
+        super().__init__(phase, wires=wires)
+
+    def error(self, *args, **kwargs):
+        return MultiplicativeError(self.phase)
+
+
+class CustomErrorOp2(ErrorOperation):
+    """Custome error with additive error"""
+
+    def __init__(self, flips, wires):
+        self.flips = flips
+        super().__init__(flips, wires=wires)
+
+    def error(self, *args, **kwargs):
+        return AdditiveError(self.flips)
+
+
+_HAMILTONIAN = qml.dot([1.0, -0.5], [qml.X(0) @ qml.Y(1), qml.Y(0) @ qml.Y(1)])
+
+
+class TestSpecAndTracker:
+    """Test capture of ErrorOperation in specs and tracker."""
+
+    # TODO: remove this when support for below is present
+    # little hack for stopping device-level decomposition for custom ops
+    @staticmethod
+    def preprocess(execution_config=qml.devices.DefaultExecutionConfig):
+        """A vanilla preprocesser"""
+        return qml.transforms.core.TransformProgram(), execution_config
+
+    dev = qml.device("null.qubit", wires=2)
+    dev.preprocess = preprocess.__func__
+
+    @staticmethod
+    @qml.qnode(dev)
+    def circuit():
+        """circuit with custom ops"""
+        qml.TrotterProduct(_HAMILTONIAN, time=1.0, n=4, order=2)
+        CustomErrorOp1(0.31, [0])
+        CustomErrorOp2(0.12, [1])
+        qml.TrotterProduct(_HAMILTONIAN, time=1.0, n=4, order=4)
+        CustomErrorOp1(0.24, [1])
+        CustomErrorOp2(0.73, [0])
+        return qml.state()
+
+    errors_types = ["MultiplicativeError", "AdditiveError", "SpectralNormError"]
+
+    def test_computation(self):
+        """Test that _compute_algo_error are adding up errors as expected."""
+
+        _ = self.circuit()
+        algo_errors = _compute_algo_error(self.circuit.qtape)
+        assert len(algo_errors) == 3
+        assert all(error in algo_errors for error in self.errors_types)
+        assert algo_errors["MultiplicativeError"].error == 0.31 * 0.24
+        assert algo_errors["AdditiveError"].error == 0.73 + 0.12
+        assert algo_errors["SpectralNormError"].error == 0.25 + 0.17998560822421455
+
+    def test_specs(self):
+        """Test that specs are tracking errors as expected."""
+
+        algo_errors = qml.specs(self.circuit)()["errors"]
+        assert len(algo_errors) == 3
+        assert all(error in algo_errors for error in self.errors_types)
+        assert algo_errors["MultiplicativeError"].error == 0.31 * 0.24
+        assert algo_errors["AdditiveError"].error == 0.73 + 0.12
+        assert algo_errors["SpectralNormError"].error == 0.25 + 0.17998560822421455
diff --git a/tests/resource/test_specs.py b/tests/resource/test_specs.py
index be31aaf13..788348d24 100644
--- a/tests/resource/test_specs.py
+++ b/tests/resource/test_specs.py
@@ -25,7 +25,7 @@ class TestSpecsTransform:
     """Tests for the transform specs using the QNode"""
 
     @pytest.mark.parametrize(
-        "diff_method, len_info", [("backprop", 11), ("parameter-shift", 12), ("adjoint", 11)]
+        "diff_method, len_info", [("backprop", 12), ("parameter-shift", 13), ("adjoint", 12)]
     )
     def test_empty(self, diff_method, len_info):
         dev = qml.device("default.qubit", wires=1)
@@ -57,7 +57,7 @@ class TestSpecsTransform:
             assert info["gradient_fn"] == "pennylane.gradients.parameter_shift.param_shift"
 
     @pytest.mark.parametrize(
-        "diff_method, len_info", [("backprop", 11), ("parameter-shift", 12), ("adjoint", 11)]
+        "diff_method, len_info", [("backprop", 12), ("parameter-shift", 13), ("adjoint", 12)]
     )
     def test_specs(self, diff_method, len_info):
         """Test the specs transforms works in standard situations"""
@@ -102,7 +102,7 @@ class TestSpecsTransform:
             assert info["num_gradient_executions"] == 6
 
     @pytest.mark.parametrize(
-        "diff_method, len_info", [("backprop", 11), ("parameter-shift", 12), ("adjoint", 11)]
+        "diff_method, len_info", [("backprop", 12), ("parameter-shift", 13), ("adjoint", 12)]
     )
     def test_specs_state(self, diff_method, len_info):
         """Test specs works when state returned"""
@@ -173,7 +173,7 @@ class TestSpecsTransform:
         info = qml.specs(circuit, expansion_strategy="device")(params)
         assert circuit.expansion_strategy == "gradient"
 
-        assert len(info) == 11
+        assert len(info) == 12
 
     def test_gradient_transform(self):
         """Test that a gradient transform is properly labelled"""
diff --git a/tests/tape/test_qscript.py b/tests/tape/test_qscript.py
index 9c002c4ba..8493a8a80 100644
--- a/tests/tape/test_qscript.py
+++ b/tests/tape/test_qscript.py
@@ -448,7 +448,7 @@ class TestInfomationProperties:
         assert qs.specs["num_diagonalizing_gates"] == 0
         assert qs.specs["num_trainable_params"] == 0
 
-        assert len(qs.specs) == 4
+        assert len(qs.specs) == 5
 
         assert qs._specs is qs.specs
 
@@ -460,7 +460,7 @@ class TestInfomationProperties:
         specs = qs.specs
         assert qs._specs is specs
 
-        assert len(specs) == 4
+        assert len(specs) == 5
 
         gate_types = defaultdict(int, {"RX": 2, "Rot": 1, "CNOT": 1})
         gate_sizes = defaultdict(int, {1: 3, 2: 1})
diff --git a/tests/tape/test_tape.py b/tests/tape/test_tape.py
index 5a1e42d72..3f8e81380 100644
--- a/tests/tape/test_tape.py
+++ b/tests/tape/test_tape.py
@@ -551,7 +551,7 @@ class TestResourceEstimation:
         assert tape.specs["num_diagonalizing_gates"] == 0
         assert tape.specs["num_trainable_params"] == 0
 
-        assert len(tape.specs) == 4
+        assert len(tape.specs) == 5
 
     def test_specs_tape(self, make_tape):
         """Tests that regular tapes return correct specifications"""
@@ -559,7 +559,7 @@ class TestResourceEstimation:
 
         specs = tape.specs
 
-        assert len(specs) == 4
+        assert len(specs) == 5
 
         gate_sizes = defaultdict(int, {1: 3, 2: 1})
         gate_types = defaultdict(int, {"RX": 2, "Rot": 1, "CNOT": 1})
@@ -577,7 +577,7 @@ class TestResourceEstimation:
         tape = make_extendible_tape
         specs1 = tape.specs
 
-        assert len(specs1) == 4
+        assert len(specs1) == 5
 
         gate_sizes = defaultdict(int, {1: 3, 2: 1})
         gate_types = defaultdict(int, {"RX": 2, "Rot": 1, "CNOT": 1})
@@ -599,7 +599,7 @@ class TestResourceEstimation:
 
         specs2 = tape.specs
 
-        assert len(specs2) == 4
+        assert len(specs2) == 5
 
         gate_sizes = defaultdict(int, {1: 4, 2: 2})
         gate_types = defaultdict(int, {"RX": 2, "Rot": 1, "CNOT": 2, "RZ": 1})