Repository: PennyLaneAI/pennylane
Pull request #5684 (2024-05-13 15:21:43)

Add support for noise models
**Context:** Adds the ability to create noise models via `qml.NoiseModel`.

**Description of the Change:** Adds a `NoiseModel` class in noise_model.py in the `qml.noise` module.

**Benefits:** 

**Possible Drawbacks:**

**Related GitHub Issues:** [sc-63081], [sc-63082]


codecov[bot]: ## [Codecov](https://app.codecov.io/gh/PennyLaneAI/pennylane/pull/5684?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI) Report
Attention: Patch coverage is `89.81481%` with `11 lines` in your changes are missing coverage. Please review.
> Project coverage is 99.65%. Comparing base [(`9c9b6ba`)](https://app.codecov.io/gh/PennyLaneAI/pennylane/commit/9c9b6ba46756ca11fb16a44dcd37b9530ce00607?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI) to head [(`2a4c7ad`)](https://app.codecov.io/gh/PennyLaneAI/pennylane/pull/5684?dropdown=coverage&src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI).
> Report is 2 commits behind head on master.

| [Files](https://app.codecov.io/gh/PennyLaneAI/pennylane/pull/5684?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI) | Patch % | Lines |
|---|---|---|
| [pennylane/noise/noise\_model.py](https://app.codecov.io/gh/PennyLaneAI/pennylane/pull/5684?src=pr&el=tree&filepath=pennylane%2Fnoise%2Fnoise_model.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI#diff-cGVubnlsYW5lL25vaXNlL25vaXNlX21vZGVsLnB5) | 47.61% | [11 Missing :warning: ](https://app.codecov.io/gh/PennyLaneAI/pennylane/pull/5684?src=pr&el=tree&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    #5684      +/-   ##
==========================================
- Coverage   99.68%   99.65%   -0.04%     
==========================================
  Files         412      417       +5     
  Lines       38666    38625      -41     
==========================================
- Hits        38545    38490      -55     
- Misses        121      135      +14     
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/PennyLaneAI/pennylane/pull/5684?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/code/qml_noise.rst b/doc/code/qml_noise.rst
index e07cec2ad..74254e1a2 100644
--- a/doc/code/qml_noise.rst
+++ b/doc/code/qml_noise.rst
@@ -10,16 +10,21 @@ Overview
 Insertion-based noise models in PennyLane are defined via a mapping from conditionals, specified
 as :class:`~.BooleanFn` objects, to :ref:`quantum function <intro_vcirc_qfunc>`-like callables
 that contain the noisy operations to be applied, but without any return statements. Additional
-noise-related metadata can also be supplied to construct a noise model.
+noise-related metadata can also be supplied to construct a noise model using:
 
-::
+.. currentmodule:: pennylane
+
+.. autosummary::
+    :toctree: api
+
+    ~NoiseModel
 
-    NoiseModel: ({Conditional --> Callables},  metadata)
+Each conditional in the ``model_map`` evaluates the gate operations in the quantum circuit based on
+some condition of its attributes (e.g., type, parameters, wires, etc.) and use the corresponding
+callable to apply the noise operations, using the user-provided metadata (e.g., hardware topologies
+or relaxation times), whenever the condition results true.
 
-Each ``Conditional`` evaluates the gate operations in the quantum circuit based on some
-condition of its attributes (e.g., type, parameters, wires, etc.) and use the corresponding
-``Callable`` to apply the noise operations, using the user-provided metadata (e.g., hardware
-topologies or relaxation times), whenever the condition results true.
+.. _intro_boolean_fn:
 
 Boolean functions
 ^^^^^^^^^^^^^^^^^
diff --git a/doc/releases/changelog-dev.md b/doc/releases/changelog-dev.md
index 527261a78..f3140447e 100644
--- a/doc/releases/changelog-dev.md
+++ b/doc/releases/changelog-dev.md
@@ -49,8 +49,21 @@
 * The `default.tensor` device is introduced to perform tensor network simulation of a quantum circuit.
   [(#5699)](https://github.com/PennyLaneAI/pennylane/pull/5699)
 
-* A new `qml.noise` module which contains utililty functions for building `NoiseModels`.
+* A new `qml.noise` module which contains utility functions for building `NoiseModels`.
   [(#5674)](https://github.com/PennyLaneAI/pennylane/pull/5674)
+  [(#5684)](https://github.com/PennyLaneAI/pennylane/pull/5684)
+
+  ```python
+  fcond = qml.noise.op_eq(qml.X) | qml.noise.op_eq(qml.Y)
+  noise = qml.noise.partial_wires(qml.AmplitudeDamping, 0.4)
+  ```
+
+  ```pycon
+  >>> qml.NoiseModel({fcond: noise}, t1=0.04)
+  NoiseModel({
+    OpEq(PauliX) | OpEq(PauliY) = AmplitudeDamping(gamma=0.4)
+  }, t1 = 0.04)
+  ```
 
 <h3>Improvements 🛠</h3>
 
diff --git a/pennylane/__init__.py b/pennylane/__init__.py
index 1138721b7..e2a4a1982 100644
--- a/pennylane/__init__.py
+++ b/pennylane/__init__.py
@@ -18,7 +18,6 @@ PennyLane can be directly imported.
 from importlib import reload, metadata
 from sys import version_info
 
-
 import numpy as _np
 
 from semantic_version import SimpleSpec, Version
@@ -140,7 +139,9 @@ from pennylane.compiler import qjit, while_loop, for_loop
 import pennylane.compiler
 
 import pennylane.data
+
 import pennylane.noise
+from pennylane.noise import NoiseModel
 
 # Look for an existing configuration file
 default_config = Configuration("config.toml")
diff --git a/pennylane/noise/__init__.py b/pennylane/noise/__init__.py
index 5458fc874..6f7af6329 100644
--- a/pennylane/noise/__init__.py
+++ b/pennylane/noise/__init__.py
@@ -14,3 +14,4 @@
 """This module contains the functionality to work with noise models in PennyLane."""
 
 from .conditionals import wires_in, wires_eq, op_in, op_eq, partial_wires
+from .noise_model import NoiseModel
diff --git a/pennylane/noise/conditionals.py b/pennylane/noise/conditionals.py
index bc9603be6..d3051a084 100644
--- a/pennylane/noise/conditionals.py
+++ b/pennylane/noise/conditionals.py
@@ -408,6 +408,16 @@ def op_eq(ops):
     return OpEq(ops)
 
 
+def _rename(newname):
+    """Decorator function for renaming ``_partial_op`` function used in partial_wires"""
+
+    def decorator(f):
+        f.__name__ = newname
+        return f
+
+    return decorator
+
+
 def partial_wires(operation, *args, **kwargs):
     """Builds a partial function based on the given operation with
     all argument frozen except ``wires``.
@@ -454,12 +464,6 @@ def partial_wires(operation, *args, **kwargs):
     >>> rfunc(qml.RY(1.0, ["light"]))
     qml.RX(2.3, wires=["light"])
     """
-
-    def _partial_op(x):
-        """Wrapper function for partial_wires"""
-        wires = getattr(x, "wires", None) or ([x] if isinstance(x, (int, str)) else list(x))
-        return op(wires=wires)
-
     if not callable(operation):
         if args:
             raise ValueError(
@@ -480,4 +484,17 @@ def partial_wires(operation, *args, **kwargs):
 
     op = partial(operation, **{**arg_params, **kwargs})
 
+    op_name = f"{operation.__name__}("
+    for key, val in op.keywords.items():
+        op_name += f"{key}={val}, "
+    op_name = op_name[: -2 if len(op.keywords) else -1] + ")"
+
+    @_rename(op_name)
+    def _partial_op(wires, **model_kwargs):  # pylint: disable = unused-argument
+        """Wrapper function for partial_wires"""
+        wires = getattr(wires, "wires", None) or (
+            [wires] if isinstance(wires, (int, str)) else list(wires)
+        )
+        return op(wires=wires)
+
     return _partial_op
diff --git a/pennylane/noise/noise_model.py b/pennylane/noise/noise_model.py
new file mode 100644
index 000000000..12616c1d1
--- /dev/null
+++ b/pennylane/noise/noise_model.py
@@ -0,0 +1,141 @@
+# Copyright 2018-2024 Xanadu Quantum Technologies Inc.
+
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+
+#     http://www.apache.org/licenses/LICENSE-2.0
+
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Contains class and methods for noise models"""
+
+import inspect
+
+import pennylane as qml
+
+
+class NoiseModel:
+    """Builds a noise model based on a mapping of conditionals to callables that
+    defines noise operations using some optional metadata.
+
+    Args:
+        model_map (dict[BooleanFn -> Callable]): Data for the
+            noise model as a ``{conditional: noise_fn}`` dictionary. The signature of
+            ``noise_fn`` must be ``noise_fn(op: Operation, **kwargs) -> None``, where
+            ``op`` is the operation that the conditional evaluates and ``kwargs`` are
+            the specified metadata arguments.
+        **kwargs: Keyword arguments for specifying metadata related to noise model.
+
+    .. note::
+
+        For each key-value pair of ``model_map``:
+
+        - The ``conditional`` should be either a function decorated with :class:`~.BooleanFn`,
+          a callable object built via :ref:`constructor functions <intro_boolean_fn>` in
+          the ``noise`` module, or their bit-wise combination.
+        - The definition of ``noise_fn(op, **kwargs)`` should have the operations in same the order
+          in which they are to be queued for an operation ``op``, whenever the corresponding
+          ``conditional`` evaluates to ``True``.
+
+    **Example**
+
+    .. code-block:: python
+
+        # Set up the conditionals
+        c0 = qml.noise.op_eq(qml.PauliX) | qml.noise.op_eq(qml.PauliY)
+        c1 = qml.noise.op_eq(qml.Hadamard) & qml.noise.wires_in([0, 1])
+
+        # Set up the noise functions
+        def n0(op, **kwargs):
+            qml.ThermalRelaxationError(0.4, kwargs["t1"], 0.2, 0.6, op.wires)
+
+        n1 = qml.noise.partial_wires(qml.AmplitudeDamping, 0.4)
+
+        # Set up noise model
+        noise_model = qml.NoiseModel({c0: n0}, t1=0.04)
+        noise_model += {c1: n1}
+
+    >>> noise_model
+    NoiseModel({
+        OpEq(PauliX) | OpEq(PauliY): n1
+        OpEq(Hadamard) & WiresIn([0, 1]): AmplitudeDamping(0.4, wires),
+    }, t1=0.04)
+    """
+
+    def __init__(self, model_map, **kwargs):
+        self.check_model(model_map)
+        self._model_map = model_map
+        self._metadata = kwargs
+
+    @property
+    def model_map(self):
+        """Gives the conditional model for the noise model"""
+        return self._model_map
+
+    @property
+    def metadata(self):
+        """Gives the metadata for the noise model"""
+        return self._metadata
+
+    def __add__(self, data):
+        if not isinstance(data, NoiseModel):
+            return NoiseModel({**self.model_map, **data}, **self.metadata)
+
+        return NoiseModel(
+            {**self.model_map, **data.model_map}, **{**self.metadata, **data.metadata}
+        )
+
+    def __radd__(self, data):
+        return self.__add__(data)
+
+    def __sub__(self, data):
+        if not isinstance(data, NoiseModel):
+            return NoiseModel(
+                {k: v for k, v in self.model_map.items() if k not in data}, **self.metadata
+            )
+
+        return NoiseModel(
+            {k: v for k, v in self.model_map.items() if k not in data.model_map},
+            **dict({k: v for k, v in self.metadata.items() if k not in data.metadata}),
+        )
+
+    def __eq__(self, other):
+        for model1, model2 in zip(self.model_map.items(), other.model_map.items()):
+            (fcond1, noise1), (fcond2, noise2) = model1, model2
+            if getattr(fcond1, "condition", fcond1.fn) != getattr(fcond2, "condition", fcond2.fn):
+                return False
+            if noise1 != noise2:
+                return False
+        return self.metadata == other.metadata
+
+    def __repr__(self):
+        model_str = "NoiseModel({\n"
+        for key, val in self.model_map.items():
+            model_str += "    " + f"{key}: {val.__name__}" + "\n"
+        model_str += "}, "
+        for key, val in self._metadata.items():
+            model_str += f"{key} = {val}, "
+        model_str = model_str[:-2] + ")"
+
+        return model_str
+
+    @staticmethod
+    def check_model(model):
+        """Method to validate the ``model`` map for constructing a NoiseModel."""
+        for condition, noise in model.items():
+            if not isinstance(condition, qml.BooleanFn):
+                raise ValueError(
+                    f"{condition} must be a boolean conditional, i.e., an instance of"
+                    "BooleanFn or one of its subclasses."
+                )
+
+            final_parameter = list(inspect.signature(noise).parameters.values())[-1]
+            if final_parameter.kind != final_parameter.VAR_KEYWORD:
+                raise ValueError(
+                    f"{noise} provided for {condition} must accept **kwargs "
+                    "as the last argument in its signature."
+                )


Test patch:
diff --git a/tests/noise/test_noise_model.py b/tests/noise/test_noise_model.py
new file mode 100644
index 000000000..7743da130
--- /dev/null
+++ b/tests/noise/test_noise_model.py
@@ -0,0 +1,143 @@
+# Copyright 2018-2024 Xanadu Quantum Technologies Inc.
+
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+
+#     http://www.apache.org/licenses/LICENSE-2.0
+
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""
+Unit tests for the available conditional utitlities for noise models.
+"""
+
+# pylint: disable = too-few-public-methods
+import pytest
+
+import pennylane as qml
+from pennylane.noise import op_eq, op_in, partial_wires, wires_eq, wires_in
+
+
+class TestNoiseModels:
+    """Test for Noise Models class and its methods"""
+
+    @pytest.mark.parametrize(
+        ("fcond", "noise"),
+        [
+            (op_eq(qml.X) | op_eq(qml.Y), partial_wires(qml.AmplitudeDamping, 0.4)),
+            (op_eq(qml.X) & op_eq(qml.Y), partial_wires(qml.RX, 0.4)),
+            (wires_eq(qml.X(0)) & op_eq(qml.X(0)), partial_wires(qml.PauliRot(1.2, "XY", [0, 1]))),
+            (~wires_eq(qml.X(0)) ^ op_eq(qml.X(0)), lambda op, **kwargs: qml.RZ(0.2, op.wires)),
+            (
+                wires_in(qml.CNOT([0, 1])) | ~op_in(["RY", "RZ"]),
+                lambda op, **kwargs: qml.RX(0.2, op.wires),
+            ),
+            (wires_in(["a", "b"]) & op_eq("RY"), lambda op, **kwargs: qml.RX(0.2, op.wires)),
+        ],
+    )
+    def test_building_noise_model(self, fcond, noise):
+        """Test that noise models are built correctly using NoiseModels"""
+
+        noise_model = qml.NoiseModel({fcond: noise}, t1=0.04, t2=0.02)
+
+        assert fcond in noise_model.model_map
+        assert noise in noise_model.model_map.values()
+        assert all(t in noise_model.metadata for t in ["t1", "t2"])
+        assert list(noise_model.metadata.values()) == [0.04, 0.02]
+        assert (
+            repr(noise_model) == "NoiseModel({\n"
+            f"    {fcond}: {noise.__name__}" + "\n"
+            "}, t1 = 0.04, t2 = 0.02)"
+        )
+
+    # pylint: disable=comparison-with-callable
+    def test_add_noise_models(self):
+        """Test that noise models can be added and manipulated"""
+
+        fcond = qml.noise.op_eq(qml.X) | qml.noise.op_eq(qml.Y)
+        noise = qml.noise.partial_wires(qml.AmplitudeDamping, 0.4)
+        noise_model = qml.NoiseModel({fcond: noise}, t1=0.04)
+
+        @qml.BooleanFn
+        def fcond1(op):
+            return isinstance(op, qml.RY) and op.parameters[0] >= 0.5
+
+        noise1 = qml.noise.partial_wires(qml.PhaseDamping, 0.9)
+        add_model = noise_model + {fcond1: noise1}
+        assert add_model.model_map[fcond1] == noise1
+
+        radd_model = {fcond1: noise1} + noise_model
+        assert radd_model == add_model
+
+        noise_model1 = qml.NoiseModel({fcond1: noise1}, t2=0.02)
+        nadd_model = noise_model + noise_model1
+        assert nadd_model.model_map[fcond1] == noise1
+        assert nadd_model.metadata["t2"] == noise_model1.metadata["t2"]
+
+    # pylint: disable=comparison-with-callable
+    def test_sub_noise_models(self):
+        """Test that noise models can be subtracted and manipulated"""
+
+        fcond = qml.noise.op_eq(qml.X) | qml.noise.op_eq(qml.Y)
+        noise = qml.noise.partial_wires(qml.AmplitudeDamping, 0.4)
+
+        @qml.BooleanFn
+        def fcond1(op):
+            return isinstance(op, qml.RY) and op.parameters[0] >= 0.5
+
+        noise1 = qml.noise.partial_wires(qml.PhaseDamping, 0.9)
+        noise_model = qml.NoiseModel({fcond: noise, fcond1: noise1}, t1=0.04, t2=0.02)
+
+        sub_model = noise_model - {fcond1: noise1}
+        assert qml.NoiseModel({fcond: noise}, t1=0.04, t2=0.02) == sub_model
+
+        sub_model1 = noise_model - qml.NoiseModel({fcond1: noise1}, t2=0.02)
+        assert qml.NoiseModel({fcond: noise}, t1=0.04) == sub_model1
+
+    # pylint: disable=comparison-with-callable, unused-argument
+    def test_eq_noise_models(self):
+        """Test that noise models can be subtracted and manipulated"""
+
+        fcond = qml.noise.op_eq(qml.X) | qml.noise.op_eq(qml.Y)
+
+        def noise(op, **kwargs):
+            qml.RX(op.parameters[0] * 0.05, op.wires)
+
+        # explicit construction
+        noise_model = qml.NoiseModel({fcond: noise})
+        noise_model2 = qml.NoiseModel({fcond: noise})
+        assert noise_model == noise_model2
+
+        # implicit construction
+        noise_model = qml.NoiseModel({qml.noise.op_eq(qml.X): noise})
+        noise_model2 = qml.NoiseModel({qml.noise.op_eq(qml.X): noise})
+        assert noise_model == noise_model2
+
+        # check inequality
+        @qml.BooleanFn
+        def fcond1(op):
+            return isinstance(op, qml.RY) and op.parameters[0] >= 0.5
+
+        noise_model = qml.NoiseModel({fcond1: noise})
+        noise_model2 = qml.NoiseModel({fcond: noise})
+        assert noise_model != noise_model2
+
+        noise1 = qml.noise.partial_wires(qml.AmplitudeDamping, 0.4)
+        noise_model = qml.NoiseModel({fcond: noise})
+        noise_model2 = qml.NoiseModel({fcond: noise1})
+        assert noise_model != noise_model2
+
+    def test_build_model_errors(self):
+        """Test for checking building noise models raise correct error when signatures are not proper"""
+
+        with pytest.raises(ValueError, match="must be a boolean conditional"):
+            qml.NoiseModel({lambda x: x: qml.noise.partial_wires(qml.X)})
+
+        with pytest.raises(
+            ValueError, match=r"must accept \*\*kwargs as the last argument in its signature"
+        ):
+            qml.NoiseModel({qml.noise.op_eq(qml.X): lambda x: x})