diff --git a/graphix/channels.py b/graphix/channels.py index 8bb0ebc1b..9339a2dbf 100644 --- a/graphix/channels.py +++ b/graphix/channels.py @@ -296,3 +296,55 @@ def two_qubit_depolarising_tensor_channel(prob: float) -> KrausChannel: KrausData(prob / 3.0, np.kron(Ops.Z, Ops.Y)), ] ) +def amplitude_damping_channel(prob: float) -> KrausChannel: + r"""Single-qubit amplitude damping channel. + + .. math:: + K_1 = \begin{pmatrix} 1 & 0 \\ 0 & \sqrt{1-\gamma} \end{pmatrix}, \quad + K_2 = \begin{pmatrix} 0 & \sqrt{\gamma} \\ 0 & 0 \end{pmatrix} + + Parameters + ---------- + prob : float + The damping parameter :math:`\gamma` associated to the channel. + + Returns + ------- + :class:`graphix.channels.KrausChannel` object + containing the corresponding Kraus operators + """ + return KrausChannel( + [ + KrausData(1.0, np.array([[1.0, 0.0], [0.0, np.sqrt(1 - prob)]], dtype=np.complex128)), + KrausData(1.0, np.array([[0.0, np.sqrt(prob)], [0.0, 0.0]], dtype=np.complex128)), + ] + ) + + +def two_qubit_amplitude_damping_channel(prob: float) -> KrausChannel: + r"""Two-qubit amplitude damping channel. + + Tensor product of two independent single-qubit amplitude damping channels + with the same damping parameter :math:`\gamma`, giving the four Kraus + operators :math:`\{K_i \otimes K_j\}` for :math:`i, j \in \{1, 2\}`. + + Parameters + ---------- + prob : float + The damping parameter :math:`\gamma` associated to the channel. + + Returns + ------- + :class:`graphix.channels.KrausChannel` object + containing the corresponding Kraus operators + """ + k1 = np.array([[1.0, 0.0], [0.0, np.sqrt(1 - prob)]], dtype=np.complex128) + k2 = np.array([[0.0, np.sqrt(prob)], [0.0, 0.0]], dtype=np.complex128) + return KrausChannel( + [ + KrausData(1.0, np.kron(k1, k1)), + KrausData(1.0, np.kron(k1, k2)), + KrausData(1.0, np.kron(k2, k1)), + KrausData(1.0, np.kron(k2, k2)), + ] + ) diff --git a/graphix/noise_models/__init__.py b/graphix/noise_models/__init__.py index 1d74beaac..fb7a375e3 100644 --- a/graphix/noise_models/__init__.py +++ b/graphix/noise_models/__init__.py @@ -4,6 +4,11 @@ from typing import TYPE_CHECKING +from graphix.noise_models.amplitude_damping import ( + AmplitudeDampingNoise, + AmplitudeDampingNoiseModel, + TwoQubitAmplitudeDampingNoise, +) from graphix.noise_models.depolarising import DepolarisingNoise, DepolarisingNoiseModel, TwoQubitDepolarisingNoise from graphix.noise_models.noise_model import ( ApplyNoise, @@ -16,11 +21,14 @@ from graphix.noise_models.noise_model import CommandOrNoise as CommandOrNoise __all__ = [ + "AmplitudeDampingNoise", + "AmplitudeDampingNoiseModel", "ApplyNoise", "ComposeNoiseModel", "DepolarisingNoise", "DepolarisingNoiseModel", "Noise", "NoiseModel", + "TwoQubitAmplitudeDampingNoise", "TwoQubitDepolarisingNoise", ] diff --git a/graphix/noise_models/amplitude_damping.py b/graphix/noise_models/amplitude_damping.py new file mode 100644 index 000000000..b280019b3 --- /dev/null +++ b/graphix/noise_models/amplitude_damping.py @@ -0,0 +1,153 @@ +"""Amplitude damping noise model.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import typing_extensions + +from graphix.channels import ( + KrausChannel, + amplitude_damping_channel, + two_qubit_amplitude_damping_channel, +) +from graphix.command import BaseM, CommandKind +from graphix.noise_models.noise_model import ApplyNoise, Noise, NoiseModel +from graphix.utils import Probability + +if TYPE_CHECKING: + from collections.abc import Iterable + + from numpy.random import Generator + + from graphix.measurements import Outcome + from graphix.noise_models.noise_model import CommandOrNoise + + +class AmplitudeDampingNoise(Noise): + """One-qubit amplitude damping noise with damping parameter ``prob``.""" + + prob = Probability() + + def __init__(self, prob: float) -> None: + r"""Initialize one-qubit amplitude damping noise. + + Parameters + ---------- + prob : float + Damping parameter :math:`\\gamma` of the noise, between 0 and 1. + """ + self.prob = prob + + @property + @typing_extensions.override + def nqubits(self) -> int: + """Return the number of qubits targetted by the noise element.""" + return 1 + + @typing_extensions.override + def to_kraus_channel(self) -> KrausChannel: + """Return the Kraus channel describing the noise element.""" + return amplitude_damping_channel(self.prob) + + +class TwoQubitAmplitudeDampingNoise(Noise): + """Two-qubit amplitude damping noise with damping parameter ``prob``.""" + + prob = Probability() + + def __init__(self, prob: float) -> None: + r"""Initialize two-qubit amplitude damping noise. + + Parameters + ---------- + prob : float + Damping parameter :math:`\\gamma` of the noise, between 0 and 1. + """ + self.prob = prob + + @property + @typing_extensions.override + def nqubits(self) -> int: + """Return the number of qubits targetted by the noise element.""" + return 2 + + @typing_extensions.override + def to_kraus_channel(self) -> KrausChannel: + """Return the Kraus channel describing the noise element.""" + return two_qubit_amplitude_damping_channel(self.prob) + + +class AmplitudeDampingNoiseModel(NoiseModel): + r"""Amplitude damping noise model. + + :param NoiseModel: Parent abstract class class:`NoiseModel` + :type NoiseModel: class + """ + + def __init__( + self, + prepare_error_prob: float = 0.0, + x_error_prob: float = 0.0, + z_error_prob: float = 0.0, + entanglement_error_prob: float = 0.0, + measure_channel_prob: float = 0.0, + ) -> None: + self.prepare_error_prob = prepare_error_prob + self.x_error_prob = x_error_prob + self.z_error_prob = z_error_prob + self.entanglement_error_prob = entanglement_error_prob + self.measure_channel_prob = measure_channel_prob + + @typing_extensions.override + def input_nodes( + self, nodes: Iterable[int], rng: Generator | None = None, *, stacklevel: int = 1 + ) -> list[CommandOrNoise]: + """Return the noise to apply to input nodes.""" + return [ApplyNoise(noise=AmplitudeDampingNoise(self.prepare_error_prob), nodes=[node]) for node in nodes] + + @typing_extensions.override + def command( + self, cmd: CommandOrNoise, rng: Generator | None = None, *, stacklevel: int = 1 + ) -> list[CommandOrNoise]: + """Return the noise to apply to the command ``cmd``.""" + match cmd.kind: + case CommandKind.N: + return [cmd, ApplyNoise(noise=AmplitudeDampingNoise(self.prepare_error_prob), nodes=[cmd.node])] + case CommandKind.E: + return [ + cmd, + ApplyNoise( + noise=TwoQubitAmplitudeDampingNoise(self.entanglement_error_prob), nodes=list(cmd.nodes) + ), + ] + case CommandKind.M: + return [ApplyNoise(noise=AmplitudeDampingNoise(self.measure_channel_prob), nodes=[cmd.node]), cmd] + case CommandKind.X: + return [ + cmd, + ApplyNoise(noise=AmplitudeDampingNoise(self.x_error_prob), nodes=[cmd.node], domain=cmd.domain), + ] + case CommandKind.Z: + return [ + cmd, + ApplyNoise(noise=AmplitudeDampingNoise(self.z_error_prob), nodes=[cmd.node], domain=cmd.domain), + ] + case CommandKind.C | CommandKind.T | CommandKind.ApplyNoise: + return [cmd] + case CommandKind.S: + raise ValueError("Unexpected signal!") + case _: + typing_extensions.assert_never(cmd.kind) + + @typing_extensions.override + def confuse_result( + self, cmd: BaseM, result: Outcome, rng: Generator | None = None, *, stacklevel: int = 1 + ) -> Outcome: + r"""Return the measurement result unchanged. + + Amplitude damping is a purely quantum channel and introduces no + classical readout error. Compose this model with another noise + model (e.g. via :class:`ComposeNoiseModel`) to add readout error. + """ + return result diff --git a/tests/test_density_matrix.py b/tests/test_density_matrix.py index 7c67909c5..df0d5b3be 100644 --- a/tests/test_density_matrix.py +++ b/tests/test_density_matrix.py @@ -12,7 +12,13 @@ import graphix.random_objects as randobj from graphix import command from graphix.branch_selector import ConstBranchSelector -from graphix.channels import KrausChannel, dephasing_channel, depolarising_channel +from graphix.channels import ( + KrausChannel, + amplitude_damping_channel, + dephasing_channel, + depolarising_channel, + two_qubit_amplitude_damping_channel, +) from graphix.fundamentals import ANGLE_PI, Plane from graphix.ops import Ops from graphix.sim.density_matrix import DensityMatrix, DensityMatrixBackend @@ -736,6 +742,92 @@ def test_apply_depolarising_channel(self, fx_rng: Generator) -> None: assert np.allclose(expected_dm.trace(), 1.0) assert np.allclose(dm.rho, expected_dm) + def test_apply_amplitude_damping_channel(self, fx_rng: Generator) -> None: + # check on single qubit first, against the by-hand Kraus sum + dm = DensityMatrix(randobj.rand_dm(2, fx_rng)) + rho_test = dm.rho + + gamma = fx_rng.uniform() + ad_channel = amplitude_damping_channel(gamma) + + assert isinstance(ad_channel, KrausChannel) + + dm.apply_channel(ad_channel, [0]) + + k1 = np.array([[1.0, 0.0], [0.0, np.sqrt(1 - gamma)]], dtype=np.complex128) + k2 = np.array([[0.0, np.sqrt(gamma)], [0.0, 0.0]], dtype=np.complex128) + expected_dm = k1 @ rho_test @ k1.conj().T + k2 @ rho_test @ k2.conj().T + + assert np.allclose(expected_dm.trace(), 1.0) + assert np.allclose(dm.rho, expected_dm) + + # check embedded in a larger random register + nqubits = int(fx_rng.integers(2, 5)) + i = int(fx_rng.integers(0, nqubits)) + + psi = _randstate_raw(nqubits, fx_rng) + psi /= np.sqrt(np.sum(np.abs(psi) ** 2)) + dm = DensityMatrix(data=np.outer(psi, psi.conj())) + + gamma = fx_rng.uniform() + ad_channel = amplitude_damping_channel(gamma) + dm.apply_channel(ad_channel, [i]) + + expected_dm = np.zeros((2**nqubits, 2**nqubits), dtype=np.complex128) + for elem in ad_channel: + psi_evolved = np.tensordot(elem.operator, psi.reshape((2,) * nqubits), (1, i)) + psi_evolved = np.moveaxis(psi_evolved, 0, i).reshape(2**nqubits) + expected_dm += elem.coef * np.conj(elem.coef) * np.outer(psi_evolved, psi_evolved.conj()) + + assert np.allclose(expected_dm.trace(), 1.0) + assert np.allclose(dm.rho, expected_dm) + + @pytest.mark.parametrize("gamma", [0.0, 0.2, 0.5, 0.9, 1.0]) + def test_amplitude_damping_ground_state_fixed(self, gamma: float) -> None: + #``|0><0|`` is a fixed point of amplitude damping for any gamma. + ket0 = np.array([[1.0, 0.0], [0.0, 0.0]], dtype=np.complex128) + dm = DensityMatrix(data=BasicStates.ZERO) + dm.apply_channel(amplitude_damping_channel(gamma), [0]) + assert np.allclose(dm.rho, ket0) + + @pytest.mark.parametrize("gamma", [0.0, 0.2, 0.5, 0.9, 1.0]) + def test_amplitude_damping_excited_state_decays(self, gamma: float) -> None: + #``|1><1| -> (1 - gamma)|1><1| + gamma|0><0|`` (directional T1 decay). + ket0 = np.array([[1.0, 0.0], [0.0, 0.0]], dtype=np.complex128) + ket1 = np.array([[0.0, 0.0], [0.0, 1.0]], dtype=np.complex128) + dm = DensityMatrix(data=BasicStates.ONE) + dm.apply_channel(amplitude_damping_channel(gamma), [0]) + assert np.allclose(dm.rho, (1 - gamma) * ket1 + gamma * ket0) + + @pytest.mark.parametrize("gamma", [0.1, 0.4, 0.8]) + def test_amplitude_damping_coherence_decay(self, gamma: float) -> None: + #Off-diagonal coherences scale by ``sqrt(1 - gamma)`` (distinct from dephasing). + dm = DensityMatrix(data=BasicStates.PLUS) + dm.apply_channel(amplitude_damping_channel(gamma), [0]) + assert np.isclose(dm.rho[0, 1], np.sqrt(1 - gamma) / 2) + assert np.isclose(dm.rho[1, 0], np.sqrt(1 - gamma) / 2) + + @pytest.mark.parametrize("gamma", [0.0, 0.25, 0.6, 1.0]) + def test_apply_two_qubit_amplitude_damping_channel(self, gamma: float, fx_rng: Generator) -> None: + #The two-qubit channel equals independent damping on each factor. + a = _randstate_raw(1, fx_rng) + a /= np.sqrt(np.sum(np.abs(a) ** 2)) + b = _randstate_raw(1, fx_rng) + b /= np.sqrt(np.sum(np.abs(b) ** 2)) + rho_a = np.outer(a, a.conj()) + rho_b = np.outer(b, b.conj()) + + dm = DensityMatrix(data=np.kron(rho_a, rho_b)) + dm.apply_channel(two_qubit_amplitude_damping_channel(gamma), [0, 1]) + + k1 = np.array([[1.0, 0.0], [0.0, np.sqrt(1 - gamma)]], dtype=np.complex128) + k2 = np.array([[0.0, np.sqrt(gamma)], [0.0, 0.0]], dtype=np.complex128) + single = lambda rho: k1 @ rho @ k1.conj().T + k2 @ rho @ k2.conj().T # noqa: E731 + expected = np.kron(single(rho_a), single(rho_b)) + + assert np.allclose(expected.trace(), 1.0) + assert np.allclose(dm.rho, expected) + def test_apply_random_channel_one_qubit(self, fx_rng: Generator) -> None: """Test using complex parameters.""" # check against statevector backend by hand for now. diff --git a/tests/test_kraus.py b/tests/test_kraus.py index f7e4983f0..8a6b9fd0f 100644 --- a/tests/test_kraus.py +++ b/tests/test_kraus.py @@ -9,8 +9,10 @@ from graphix.channels import ( KrausChannel, KrausData, + amplitude_damping_channel, dephasing_channel, depolarising_channel, + two_qubit_amplitude_damping_channel, two_qubit_depolarising_channel, two_qubit_depolarising_tensor_channel, ) @@ -180,3 +182,41 @@ def test_2_qubit_depolarising_tensor_channel(self, fx_rng: Generator) -> None: for i in range(len(depol_tensor_channel_2_qubit)): assert np.allclose(depol_tensor_channel_2_qubit[i].coef, data[i].coef) assert np.allclose(depol_tensor_channel_2_qubit[i].operator, data[i].operator) + + def test_amplitude_damping_channel(self, fx_rng: Generator) -> None: + gamma = fx_rng.uniform() + data = [ + KrausData(1.0, np.array([[1.0, 0.0], [0.0, np.sqrt(1 - gamma)]], dtype=np.complex128)), + KrausData(1.0, np.array([[0.0, np.sqrt(gamma)], [0.0, 0.0]], dtype=np.complex128)), + ] + + ad_channel = amplitude_damping_channel(gamma) + + assert isinstance(ad_channel, KrausChannel) + assert ad_channel.nqubit == 1 + assert len(ad_channel) == 2 + + for i in range(len(ad_channel)): + assert np.allclose(ad_channel[i].coef, data[i].coef) + assert np.allclose(ad_channel[i].operator, data[i].operator) + + def test_2_qubit_amplitude_damping_channel(self, fx_rng: Generator) -> None: + gamma = fx_rng.uniform() + k1 = np.array([[1.0, 0.0], [0.0, np.sqrt(1 - gamma)]], dtype=np.complex128) + k2 = np.array([[0.0, np.sqrt(gamma)], [0.0, 0.0]], dtype=np.complex128) + data = [ + KrausData(1.0, np.kron(k1, k1)), + KrausData(1.0, np.kron(k1, k2)), + KrausData(1.0, np.kron(k2, k1)), + KrausData(1.0, np.kron(k2, k2)), + ] + + ad_channel_2_qubit = two_qubit_amplitude_damping_channel(gamma) + + assert isinstance(ad_channel_2_qubit, KrausChannel) + assert ad_channel_2_qubit.nqubit == 2 + assert len(ad_channel_2_qubit) == 4 + + for i in range(len(ad_channel_2_qubit)): + assert np.allclose(ad_channel_2_qubit[i].coef, data[i].coef) + assert np.allclose(ad_channel_2_qubit[i].operator, data[i].operator) diff --git a/tests/test_noise_model.py b/tests/test_noise_model.py index 3babd2a4c..bccbad640 100644 --- a/tests/test_noise_model.py +++ b/tests/test_noise_model.py @@ -6,12 +6,15 @@ import pytest from graphix import Pattern -from graphix.command import CommandKind, M, N +from graphix.command import CommandKind, E, M, N, X from graphix.noise_models import ( + AmplitudeDampingNoise, + AmplitudeDampingNoiseModel, ApplyNoise, ComposeNoiseModel, DepolarisingNoise, DepolarisingNoiseModel, + TwoQubitAmplitudeDampingNoise, TwoQubitDepolarisingNoise, ) from graphix.noise_models.noise_model import NoiselessNoiseModel @@ -114,3 +117,114 @@ def test_confuse_result(fx_rng: Generator) -> None: backend="densitymatrix", noise_model=noise_model, rng=fx_rng, measure_method=measure_method ) assert measure_method.results[0] == 1 + + +def test_amplitude_damping_command_injection() -> None: + """Amplitude damping noise is injected at the correct command positions.""" + model = AmplitudeDampingNoiseModel( + prepare_error_prob=0.1, + x_error_prob=0.2, + entanglement_error_prob=0.3, + measure_channel_prob=0.4, + ) + + # N: noise applied AFTER preparation + out = model.command(N(node=0)) + assert len(out) == 2 + assert out[0].kind == CommandKind.N + assert isinstance(out[1], ApplyNoise) + assert isinstance(out[1].noise, AmplitudeDampingNoise) + assert out[1].nodes == [0] + + # E: two-qubit noise applied AFTER entanglement + out = model.command(E(nodes=(0, 1))) + assert out[0].kind == CommandKind.E + assert isinstance(out[1], ApplyNoise) + assert isinstance(out[1].noise, TwoQubitAmplitudeDampingNoise) + assert out[1].noise.nqubits == 2 + + # M: noise applied BEFORE measurement + out = model.command(M(node=0)) + assert isinstance(out[0], ApplyNoise) + assert isinstance(out[0].noise, AmplitudeDampingNoise) + assert out[1].kind == CommandKind.M + + # X: correction kept, noise conditioned on the same domain + out = model.command(X(node=0, domain={1, 2})) + assert out[0].kind == CommandKind.X + assert isinstance(out[1], ApplyNoise) + assert out[1].domain == {1, 2} + + +@pytest.mark.parametrize("outcome", [0, 1]) +def test_amplitude_damping_confuse_result_is_identity(outcome: int) -> None: + """Amplitude damping introduces no classical readout error.""" + model = AmplitudeDampingNoiseModel() + assert model.confuse_result(M(node=0), outcome) == outcome + + +def test_compose_amplitude_damping_depolarising_transpile(fx_rng: Generator) -> None: + #Compose an amplitude damping and a depolarising model, and verify that each composed command injects the two models' noise in order. + nqubits = 5 + depth = 5 + circuit = rand_circuit(nqubits, depth, rng=fx_rng) + pattern = circuit.transpile().pattern + noise_model = ComposeNoiseModel( + [AmplitudeDampingNoiseModel(x_error_prob=0.5), DepolarisingNoiseModel(z_error_prob=0.5)] + ) + noisy_pattern = noise_model.transpile(pattern, rng=fx_rng) + iterator = iter(noisy_pattern) + + def check_ad(cmd: CommandOrNoise, prob: float, two_qubits: bool) -> None: + assert isinstance(cmd, ApplyNoise) + if two_qubits: + assert isinstance(cmd.noise, TwoQubitAmplitudeDampingNoise) + else: + assert isinstance(cmd.noise, AmplitudeDampingNoise) + assert cmd.noise.prob == prob + + def check_depol(cmd: CommandOrNoise, prob: float, two_qubits: bool) -> None: + assert isinstance(cmd, ApplyNoise) + if two_qubits: + assert isinstance(cmd.noise, TwoQubitDepolarisingNoise) + else: + assert isinstance(cmd.noise, DepolarisingNoise) + assert cmd.noise.prob == prob + + for cmd in pattern: + if cmd.kind == CommandKind.M: + # measurement-channel noise injected BEFORE M; prepend reverses the + # order relative to appended slots, so AD precedes depol here + check_ad(next(iterator), 0, False) + check_depol(next(iterator), 0, False) + assert next(iterator) == cmd + match cmd.kind: + case CommandKind.N: + check_depol(next(iterator), 0, False) + check_ad(next(iterator), 0, False) + case CommandKind.E: + check_depol(next(iterator), 0, True) + check_ad(next(iterator), 0, True) + case CommandKind.X: + # depol carries 0 on X, AD carries x_error_prob=0.5 + check_depol(next(iterator), 0, False) + check_ad(next(iterator), 0.5, False) + case CommandKind.Z: + # depol carries z_error_prob=0.5, AD carries 0 on Z + check_depol(next(iterator), 0.5, False) + check_ad(next(iterator), 0, False) + + +def test_compose_amplitude_damping_depolarising_simulation(fx_rng: Generator) -> None: + """A composed model with both noiseless-configured models reproduces the ideal state.""" + nqubits = 5 + depth = 5 + circuit = rand_circuit(nqubits, depth, rng=fx_rng) + state = circuit.simulate_statevector().statevec + pattern = circuit.transpile().pattern + pattern.standardize() + pattern.minimize_space() + # both models default to noiseless (all probs 0) + noise_model = ComposeNoiseModel([AmplitudeDampingNoiseModel(), DepolarisingNoiseModel()]) + state_mbqc = pattern.simulate_pattern(backend="densitymatrix", noise_model=noise_model, rng=fx_rng) + assert np.abs(np.dot(state_mbqc.flatten().conjugate(), DensityMatrix(state).rho.flatten())) == pytest.approx(1)