diff --git a/doc/introduction/operations.rst b/doc/introduction/operations.rst index 9666fc58184..970e7b4ecd9 100644 --- a/doc/introduction/operations.rst +++ b/doc/introduction/operations.rst @@ -495,6 +495,21 @@ Qutrit State preparation :html:`` +.. _intro_ref_ops_qutrit_channels: + +Qutrit noisy channels +^^^^^^^^^^^^^^^^^^^^^^^^ + + +:html:`
` + +.. autosummary:: + :nosignatures: + + ~pennylane.QutritDepolarizingChannel + +:html:`
` + .. _intro_ref_ops_qutrit_obs: Qutrit Observables diff --git a/doc/releases/changelog-dev.md b/doc/releases/changelog-dev.md index 0b6587eacb6..01db3008d03 100644 --- a/doc/releases/changelog-dev.md +++ b/doc/releases/changelog-dev.md @@ -98,6 +98,11 @@ * ``qml.load`` has been removed in favour of more specific functions, such as ``qml.from_qiskit``, etc. [(#5654)](https://github.com/PennyLaneAI/pennylane/pull/5654) +

Community contributions 🥳

+ +* ``qml.QutritDepolarizingChannel`` has been added, allowing for depolarizing noise to be simulated on the `default.qutrit.mixed` device. + [(#5502)](https://github.com/PennyLaneAI/pennylane/pull/5502) +

Deprecations 👋

* ``qml.transforms.map_batch_transform`` is deprecated, since a transform can be applied directly to a batch of tapes. @@ -131,6 +136,7 @@ This release contains contributions from (in alphabetical order): +Gabriel Bottrill, Isaac De Vlugt, Pietropaolo Frisoni, Soran Jahangiri, diff --git a/pennylane/devices/default_qutrit_mixed.py b/pennylane/devices/default_qutrit_mixed.py index 7f98a9497c0..7c264a9d785 100644 --- a/pennylane/devices/default_qutrit_mixed.py +++ b/pennylane/devices/default_qutrit_mixed.py @@ -13,7 +13,6 @@ # limitations under the License. """The default.qutrit.mixed device is PennyLane's standard qutrit simulator for mixed-state computations.""" - import inspect import logging from dataclasses import replace @@ -22,6 +21,7 @@ import numpy as np import pennylane as qml +from pennylane.ops import _qutrit__channel__ops__ as channels from pennylane.tape import QuantumTape from pennylane.transforms.core import TransformProgram from pennylane.typing import Result, ResultBatch @@ -49,7 +49,6 @@ # always a function from a resultbatch to either a result or a result batch PostprocessingFn = Callable[[ResultBatch], Result_or_ResultBatch] -channels = set() observables = { "THermitian", "GellMann", diff --git a/pennylane/ops/__init__.py b/pennylane/ops/__init__.py index ee1a9eefa3c..6beae6b802e 100644 --- a/pennylane/ops/__init__.py +++ b/pennylane/ops/__init__.py @@ -37,6 +37,7 @@ from .qutrit import __all__ as _qutrit__all__ from .qutrit import __obs__ as _qutrit__obs__ from .qutrit import __ops__ as _qutrit__ops__ +from .qutrit import __channels__ as _qutrit__channel__ops__ _qubit__ops__ = _qubit__ops__ | _controlled_qubit__ops__ _qubit__all__ = _qubit__all__ + list(_controlled_qubit__ops__) diff --git a/pennylane/ops/qutrit/__init__.py b/pennylane/ops/qutrit/__init__.py index 36aa7f22789..670f950ba45 100644 --- a/pennylane/ops/qutrit/__init__.py +++ b/pennylane/ops/qutrit/__init__.py @@ -26,6 +26,7 @@ from .observables import * from .parametric_ops import * from .state_preparation import * +from .channel import QutritDepolarizingChannel # TODO: Change `qml.Identity` for qutrit support or add `qml.TIdentity` for qutrits __ops__ = { @@ -47,5 +48,8 @@ "THermitian", "GellMann", } +__channels__ = { + "QutritDepolarizingChannel", +} -__all__ = list(__ops__ | __obs__) +__all__ = list(__ops__ | __obs__ | __channels__) diff --git a/pennylane/ops/qutrit/channel.py b/pennylane/ops/qutrit/channel.py new file mode 100644 index 00000000000..8443fd2de44 --- /dev/null +++ b/pennylane/ops/qutrit/channel.py @@ -0,0 +1,224 @@ +# 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. +# pylint: disable=too-many-arguments +""" +This module contains the available built-in noisy qutrit +quantum channels supported by PennyLane, as well as their conventions. +""" +import numpy as np + +from pennylane import math +from pennylane.operation import Channel + +QUDIT_DIM = 3 + + +class QutritDepolarizingChannel(Channel): + r""" + Single-qutrit symmetrically depolarizing error channel. + This channel is modelled by the Kraus matrices generated by the following relationship: + + .. math:: + K_0 = K_{0,0} = \sqrt{1-p} \begin{bmatrix} + 1 & 0 & 0\\ + 0 & 1 & 0\\ + 0 & 0 & 1 + \end{bmatrix}, \quad + K_{i,j} = \sqrt{\frac{p}{8}}X^iZ^j + + Where: + + .. math:: + X = \begin{bmatrix} + 0 & 1 & 0 \\ + 0 & 0 & 1 \\ + 1 & 0 & 0 + \end{bmatrix}, \quad + Z = \begin{bmatrix} + 1 & 0 & 0\\ + 0 & \omega & 0\\ + 0 & 0 & \omega^2 + \end{bmatrix} + + + These relations create the following Kraus matrices: + + .. math:: + \begin{matrix} + K_0 = K_{0,0} = \sqrt{1-p} \begin{bmatrix} + 1 & 0 & 0\\ + 0 & 1 & 0\\ + 0 & 0 & 1 + \end{bmatrix}& + K_1 = K_{0,1} = \sqrt{\frac{p}{8}}\begin{bmatrix} + 1 & 0 & 0\\ + 0 & \omega & 0\\ + 0 & 0 & \omega^2 + \end{bmatrix}& + K_2 = K_{0,2} = \sqrt{\frac{p}{8}}\begin{bmatrix} + 1 & 0 & 0\\ + 0 & \omega^2 & 0\\ + 0 & 0 & \omega + \end{bmatrix}\\ + K_3 = K_{1,0} = \sqrt{\frac{p}{8}}\begin{bmatrix} + 0 & 1 & 0 \\ + 0 & 0 & 1 \\ + 1 & 0 & 0 + \end{bmatrix}& + K_4 = K_{1,1} = \sqrt{\frac{p}{8}}\begin{bmatrix} + 0 & \omega & 0 \\ + 0 & 0 & \omega^2 \\ + 1 & 0 & 0 + \end{bmatrix}& + K_5 = K_{1,2} = \sqrt{\frac{p}{8}}\begin{bmatrix} + 0 & \omega^2 & 0 \\ + 0 & 0 & \omega \\ + 1 & 0 & 0 + \end{bmatrix}\\ + K_6 = K_{2,0} = \sqrt{\frac{p}{8}}\begin{bmatrix} + 0 & 0 & 1 \\ + 1 & 0 & 0 \\ + 0 & 1 & 0 + \end{bmatrix}& + K_7 = K_{2,1} = \sqrt{\frac{p}{8}}\begin{bmatrix} + 0 & 0 & \omega^2 \\ + 1 & 0 & 0 \\ + 0 & \omega & 0 + \end{bmatrix}& + K_8 = K_{2,2} = \sqrt{\frac{p}{8}}\begin{bmatrix} + 0 & 0 & \omega \\ + 1 & 0 & 0 \\ + 0 & \omega^2 & 0 + \end{bmatrix} + \end{matrix} + + Where :math:`\omega=\exp(\frac{2\pi}{3})` is the third root of unity, + and :math:`p \in [0, 1]` is the depolarization probability, equally + divided in the application of all qutrit Pauli operators. + + .. note:: + + The Kraus operators :math:`\{K_0 \ldots K_8\}` used are the representations of the single qutrit Pauli group. + These Pauli group operators are defined in [`1 `_] (Eq. 5). + The Kraus Matrices we use are adapted from [`2 `_] (Eq. 5). + For this definition, please make a note of the following: + + * For :math:`p = 0`, the channel will be an Identity channel, i.e., a noise-free channel. + * For :math:`p = \frac{8}{9}`, the channel will be a fully depolarizing channel. + * For :math:`p = 1`, the channel will be a uniform error channel. + + **Details:** + + * Number of wires: 1 + * Number of parameters: 1 + + Args: + p (float): Each qutrit Pauli operator is applied with probability :math:`\frac{p}{8}` + wires (Sequence[int] or int): the wire the channel acts on + id (str or None): String representing the operation (optional) + """ + + num_params = 1 + num_wires = 1 + grad_method = "A" + grad_recipe = ([[1, 0, 1], [-1, 0, 0]],) + + def __init__(self, p, wires, id=None): + super().__init__(p, wires=wires, id=id) + + @staticmethod + def compute_kraus_matrices(p): # pylint:disable=arguments-differ + r"""Kraus matrices representing the qutrit depolarizing channel. + + Args: + p (float): each qutrit Pauli gate is applied with probability :math:`\frac{p}{8}` + + Returns: + list (array): list of Kraus matrices + + **Example** + + >>> np.round(qml.QutritDepolarizingChannel.compute_kraus_matrices(0.5), 3) + array([[[ 0.707+0.j , 0. +0.j , 0. +0.j ], + [ 0. +0.j , 0.707+0.j , 0. +0.j ], + [ 0. +0.j , 0. +0.j , 0.707+0.j ]], + + [[ 0.25 +0.j , 0. +0.j , 0. +0.j ], + [ 0. +0.j , -0.125+0.217j, 0. +0.j ], + [ 0. +0.j , 0. +0.j , -0.125-0.217j]], + + [[ 0.25 +0.j , 0. +0.j , 0. +0.j ], + [ 0. +0.j , -0.125-0.217j, 0. +0.j ], + [ 0. +0.j , 0. +0.j , -0.125+0.217j]], + + [[ 0. +0.j , 0.25 +0.j , 0. +0.j ], + [ 0. +0.j , 0. +0.j , 0.25 +0.j ], + [ 0.25 +0.j , 0. +0.j , 0. +0.j ]], + + [[ 0. +0.j , -0.125+0.217j, 0. +0.j ], + [ 0. +0.j , 0. +0.j , -0.125-0.217j], + [ 0.25 +0.j , 0. +0.j , 0. +0.j ]], + + [[ 0. +0.j , -0.125-0.217j, 0. +0.j ], + [ 0. +0.j , 0. +0.j , -0.125+0.217j], + [ 0.25 +0.j , 0. +0.j , 0. +0.j ]], + + [[ 0. +0.j , 0. +0.j , 0.25 +0.j ], + [ 0.25 +0.j , 0. +0.j , 0. +0.j ], + [ 0. +0.j , 0.25 +0.j , 0. +0.j ]], + + [[ 0. +0.j , 0. +0.j , -0.125-0.217j], + [ 0.25 +0.j , 0. +0.j , 0. +0.j ], + [ 0. +0.j , -0.125+0.217j, 0. +0.j ]], + + [[ 0. +0.j , 0. +0.j , -0.125+0.217j], + [ 0.25 +0.j , 0. +0.j , 0. +0.j ], + [ 0. +0.j , -0.125-0.217j, 0. +0.j ]]]) + """ + if not math.is_abstract(p) and not 0.0 <= p <= 1.0: + raise ValueError("p must be in the interval [0,1]") + + interface = math.get_interface(p) + + w = math.exp(2j * np.pi / 3) + one = 1 + z = 0 + + if interface == "tensorflow": + p = math.cast_like(p, 1j) + w = math.cast_like(w, p) + one = math.cast_like(one, p) + z = math.cast_like(z, p) + + w2 = w**2 + + # The matrices are explicitly written, not generated to ensure PyTorch differentiation. + depolarizing_mats = [ + [[one, z, z], [z, w, z], [z, z, w2]], + [[one, z, z], [z, w2, z], [z, z, w]], + [[z, one, z], [z, z, one], [one, z, z]], + [[z, w, z], [z, z, w2], [one, z, z]], + [[z, w2, z], [z, z, w], [one, z, z]], + [[z, z, one], [one, z, z], [z, one, z]], + [[z, z, w2], [one, z, z], [z, w, z]], + [[z, z, w], [one, z, z], [z, w2, z]], + ] + + normalization = math.sqrt(p / 8 + math.eps) + Ks = [normalization * math.array(m, like=interface) for m in depolarizing_mats] + identity = math.sqrt(1 - p + math.eps) * math.array( + math.eye(QUDIT_DIM, dtype=complex), like=interface + ) + + return [identity] + Ks diff --git a/tests/devices/qutrit_mixed/test_qutrit_mixed_preprocessing.py b/tests/devices/qutrit_mixed/test_qutrit_mixed_preprocessing.py index 27bf1331d17..9574fdbec25 100644 --- a/tests/devices/qutrit_mixed/test_qutrit_mixed_preprocessing.py +++ b/tests/devices/qutrit_mixed/test_qutrit_mixed_preprocessing.py @@ -125,9 +125,10 @@ def test_measurement_is_swapped_out(self, mp_fn, mp_cls, shots): (qml.GellMann(0, 1), False), (qml.Snapshot(), True), (qml.TRX(1.1, 0), True), + (qml.QutritDepolarizingChannel(0.4, 0), True), ], ) - def test_accepted_observables(self, op, expected): + def test_accepted_operator(self, op, expected): """Test that stopping_condition works correctly""" res = stopping_condition(op) assert res == expected @@ -136,6 +137,7 @@ def test_accepted_observables(self, op, expected): "obs, expected", [ (qml.TShift(0), False), + (qml.QutritDepolarizingChannel(0.4, 0), False), (qml.GellMann(0, 1), True), (qml.Snapshot(), False), (qml.operation.Tensor(qml.GellMann(0, 1), qml.GellMann(3, 3)), True), @@ -144,7 +146,7 @@ def test_accepted_observables(self, op, expected): (qml.ops.op_math.Prod(qml.GellMann(0, 1), qml.GellMann(3, 3)), True), ], ) - def test_accepted_operator(self, obs, expected): + def test_accepted_observable(self, obs, expected): """Test that observable_stopping_condition works correctly""" res = observable_stopping_condition(obs) assert res == expected diff --git a/tests/ops/qutrit/test_qutrit_channel_ops.py b/tests/ops/qutrit/test_qutrit_channel_ops.py new file mode 100644 index 00000000000..be1025d0c26 --- /dev/null +++ b/tests/ops/qutrit/test_qutrit_channel_ops.py @@ -0,0 +1,174 @@ +# Copyright 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 built-in qutrit quantum channels. +""" +import numpy as np +import pytest +from numpy.linalg import matrix_power + +import pennylane as qml +from pennylane import numpy as pnp +from pennylane.ops.qutrit import channel + +QUDIT_DIM = 3 + + +class TestQutritDepolarizingChannel: + """Tests for the qutrit quantum channel QutritDepolarizingChannel""" + + @staticmethod + def get_expected_kraus_matrices(p): + """Gets the expected Kraus matrices given probability p.""" + w = np.exp(2j * np.pi / 3) + + X = np.array([[0, 1, 0], [0, 0, 1], [1, 0, 0]]) + Z = np.diag([1, w, w**2]) + + Ks = [np.sqrt(1 - p) * np.eye(QUDIT_DIM)] + for i in range(3): + for j in range(3): + if i == 0 and j == 0: + continue + Ks.append(np.sqrt(p / 8) * matrix_power(X, i) @ matrix_power(Z, j)) + return Ks + + def test_p_zero(self, tol): + """Test p=0 gives correct Kraus matrices""" + op = qml.QutritDepolarizingChannel + kraus_matrices = op(0, wires=0).kraus_matrices() + + assert len(kraus_matrices) == 9 + assert np.allclose(kraus_matrices[0], np.eye(QUDIT_DIM), atol=tol, rtol=0) + assert np.allclose(np.array(kraus_matrices[1:]), 0, atol=tol, rtol=0) + + def test_p_arbitrary(self, tol): + """Test p=0.1 gives correct Kraus matrices""" + p = 0.1 + kraus_matrices = qml.QutritDepolarizingChannel(p, wires=0).kraus_matrices() + expected_matrices = self.get_expected_kraus_matrices(p) + for kraus_matrix, expected_matrix in zip(kraus_matrices, expected_matrices): + assert np.allclose(kraus_matrix, expected_matrix, atol=tol, rtol=0) + + def test_p_invalid_parameter(self): + """Test that error is raised given an inappropriate p value.""" + with pytest.raises(ValueError, match="p must be in the interval"): + qml.QutritDepolarizingChannel(1.5, wires=0).kraus_matrices() + + @pytest.mark.parametrize("angle", np.linspace(0, 2 * np.pi, 7)) + def test_grad_depolarizing(self, angle): + """Test that analytical gradient is computed correctly for different states. Channel + grad recipes are independent of channel parameter""" + + dev = qml.device("default.qutrit.mixed") + prob = pnp.array(0.5, requires_grad=True) + + @qml.qnode(dev) + def circuit(p): + qml.TRX(angle, wires=0, subspace=(0, 1)) + qml.TRX(angle, wires=0, subspace=(1, 2)) + qml.QutritDepolarizingChannel(p, wires=0) + return qml.expval(qml.GellMann(0, 3) + qml.GellMann(0, 8)) + + expected_errorless = ( + (np.sqrt(3) - 3) * (1 - np.cos(2 * angle)) / 24 + - 2 / np.sqrt(3) * np.sin(angle / 2) ** 4 + + (np.sqrt(1 / 3) + 1) * np.cos(angle / 2) ** 2 + ) + + assert np.allclose(circuit(prob), ((prob - (1 / 9)) / (8 / 9)) * expected_errorless) + + gradient = np.squeeze(qml.grad(circuit)(prob)) + assert np.allclose(gradient, circuit(1) - circuit(0)) + assert np.allclose(gradient, -(9 / 8) * expected_errorless) + + @staticmethod + def expected_jac_fn(p): + """Gets the expected Jacobian of Kraus matrices given probability p.""" + w = np.exp(2j * np.pi / 3) + + X = np.array([[0, 1, 0], [0, 0, 1], [1, 0, 0]]) + Z = np.diag([1, w, w**2]) + + jacs = [-1 / (2 * np.sqrt(1 - p)) * np.eye(QUDIT_DIM)] + for i in range(3): + for j in range(3): + if i == 0 and j == 0: + continue + jacs.append(np.sqrt(1 / (32 * p)) * matrix_power(X, i) @ matrix_power(Z, j)) + + return jacs + + @staticmethod + def kraus_fn(p): + """Gets a matrix of the Kraus matrices to be tested.""" + return qml.math.stack(channel.QutritDepolarizingChannel(p, wires=0).kraus_matrices()) + + @staticmethod + def kraus_fn_real(p): + """Gets a matrix of the real part of the Kraus matrices to be tested.""" + return qml.math.real( + qml.math.stack(channel.QutritDepolarizingChannel(p, wires=0).kraus_matrices()) + ) + + @staticmethod + def kraus_fn_imag(p): + """Gets a matrix of the imaginary part of the Kraus matrices to be tested.""" + return qml.math.imag( + qml.math.stack(channel.QutritDepolarizingChannel(p, wires=0).kraus_matrices()) + ) + + @pytest.mark.autograd + def test_kraus_jac_autograd(self): + """Tests Jacobian of Kraus matrices using autograd.""" + p = pnp.array(0.43, requires_grad=True) + jac = qml.jacobian(self.kraus_fn_real)(p) + 1j * qml.jacobian(self.kraus_fn_imag)(p) + assert qml.math.allclose(jac, self.expected_jac_fn(p)) + + @pytest.mark.torch + def test_kraus_jac_torch(self): + """Tests Jacobian of Kraus matrices using torch.""" + import torch + + p = torch.tensor(0.43, requires_grad=True) + jacobian = torch.autograd.functional.jacobian + jac = jacobian(self.kraus_fn_real, p) + 1j * jacobian(self.kraus_fn_imag, p) + assert qml.math.allclose(jac, self.expected_jac_fn(p.detach().numpy())) + + @pytest.mark.tf + def test_kraus_jac_tf(self): + """Tests Jacobian of Kraus matrices using tensorflow.""" + import tensorflow as tf + + p = tf.Variable(0.43) + with tf.GradientTape() as real_tape: + real_out = self.kraus_fn_real(p) + with tf.GradientTape() as imag_tape: + imag_out = self.kraus_fn_imag(p) + + real_jac = qml.math.cast(real_tape.jacobian(real_out, p), complex) + imag_jac = qml.math.cast(imag_tape.jacobian(imag_out, p), complex) + jac = real_jac + 1j * imag_jac + assert qml.math.allclose(jac, self.expected_jac_fn(0.43)) + + @pytest.mark.jax + def test_kraus_jac_jax(self): + """Tests Jacobian of Kraus matrices using jax.""" + import jax + + jax.config.update("jax_enable_x64", True) + + p = jax.numpy.array(0.43, dtype=jax.numpy.complex128) + jac = jax.jacobian(self.kraus_fn, holomorphic=True)(p) + assert qml.math.allclose(jac, self.expected_jac_fn(p))