Source code for atlas_q.noise_models

"""
Noise Models for NISQ-Era Quantum Simulation

Implements various noise channels as Kraus operators and MPO representations:
- Depolarizing noise (1-qubit, 2-qubit)
- Dephasing (T2 decoherence)
- Amplitude damping (T1 relaxation)
- Bit flip, phase flip
- Stochastic Pauli sampling

Author: ATLAS-Q Contributors
Date: October 2025
License: MIT
"""

from dataclasses import dataclass
from typing import Dict, List, Optional, Tuple

import numpy as np
import torch


[docs] @dataclass class NoiseChannel: """ A quantum noise channel represented as Kraus operators A channel Φ(ρ) = Σᵢ Kᵢ ρ Kᵢ† where Σᵢ Kᵢ†Kᵢ = I (completeness) """ name: str kraus_ops: List[torch.Tensor] # List of Kraus operators num_qubits: int = 1 def __post_init__(self): """Validate completeness relation""" # Check Σᵢ Kᵢ†Kᵢ ≈ I identity_check = sum(K.conj().T @ K for K in self.kraus_ops) dim = 2**self.num_qubits expected_identity = torch.eye( dim, dtype=self.kraus_ops[0].dtype, device=self.kraus_ops[0].device ) error = torch.norm(identity_check - expected_identity).item() if error > 1e-6: print(f"Warning: Noise channel {self.name} completeness error: {error:.2e}")
[docs] class NoiseModel: """ Collection of noise channels applied to quantum circuits Usage: noise = NoiseModel.depolarizing(p1q=0.001, p2q=0.01) # Apply after each gate during simulation """ def __init__(self): self.channels_1q: Dict[str, NoiseChannel] = {} self.channels_2q: Dict[str, NoiseChannel] = {} self.custom_channels: Dict[Tuple[int, ...], NoiseChannel] = {} self.seed: Optional[int] = None
[docs] def set_seed(self, seed: int): """Set random seed for stochastic sampling""" self.seed = seed torch.manual_seed(seed) np.random.seed(seed)
[docs] def add_1q_channel(self, name: str, channel: NoiseChannel): """Add a 1-qubit noise channel""" assert channel.num_qubits == 1, "Must be 1-qubit channel" self.channels_1q[name] = channel
[docs] def add_2q_channel(self, name: str, channel: NoiseChannel): """Add a 2-qubit noise channel""" assert channel.num_qubits == 2, "Must be 2-qubit channel" self.channels_2q[name] = channel
def add_custom_channel(self, qubits: Tuple[int, ...], channel: NoiseChannel): """Add noise channel for specific qubits""" self.custom_channels[qubits] = channel
[docs] @staticmethod def depolarizing(p1q: float = 0.001, p2q: float = 0.01, device: str = "cuda") -> "NoiseModel": """ Depolarizing noise: ρ → (1-p)ρ + p·I/d Args: p1q: Single-qubit depolarizing probability p2q: Two-qubit depolarizing probability device: torch device Returns: NoiseModel with depolarizing channels """ model = NoiseModel() # 1-qubit depolarizing: 4 Kraus operators # K₀ = √(1-p) I, K₁ = √(p/3) X, K₂ = √(p/3) Y, K₃ = √(p/3) Z sqrt_p1 = np.sqrt(p1q / 3) sqrt_1_p1 = np.sqrt(1 - p1q) I = torch.eye(2, dtype=torch.complex64, device=device) X = torch.tensor([[0, 1], [1, 0]], dtype=torch.complex64, device=device) Y = torch.tensor([[0, -1j], [1j, 0]], dtype=torch.complex64, device=device) Z = torch.tensor([[1, 0], [0, -1]], dtype=torch.complex64, device=device) kraus_1q = [sqrt_1_p1 * I, sqrt_p1 * X, sqrt_p1 * Y, sqrt_p1 * Z] model.add_1q_channel( "depolarizing", NoiseChannel("depolarizing_1q", kraus_1q, num_qubits=1) ) # 2-qubit depolarizing: 16 Kraus operators (Pauli basis) sqrt_p2 = np.sqrt(p2q / 15) sqrt_1_p2 = np.sqrt(1 - p2q) paulis_1q = [I, X, Y, Z] kraus_2q = [] for i, P1 in enumerate(paulis_1q): for j, P2 in enumerate(paulis_1q): if i == 0 and j == 0: # K₀ = √(1-p) I⊗I kraus_2q.append(sqrt_1_p2 * torch.kron(P1, P2)) else: # Kᵢⱼ = √(p/15) Pᵢ⊗Pⱼ kraus_2q.append(sqrt_p2 * torch.kron(P1, P2)) model.add_2q_channel( "depolarizing", NoiseChannel("depolarizing_2q", kraus_2q, num_qubits=2) ) return model
[docs] @staticmethod def dephasing(p: float = 0.001, device: str = "cuda") -> "NoiseModel": """ Phase damping (T2 dephasing): ρ → (1-p)ρ + p·Z ρ Z Kraus operators: K₀ = √(1-p) I K₁ = √p Z """ model = NoiseModel() sqrt_p = np.sqrt(p) sqrt_1_p = np.sqrt(1 - p) I = torch.eye(2, dtype=torch.complex64, device=device) Z = torch.tensor([[1, 0], [0, -1]], dtype=torch.complex64, device=device) kraus = [sqrt_1_p * I, sqrt_p * Z] model.add_1q_channel("dephasing", NoiseChannel("dephasing", kraus, num_qubits=1)) return model
[docs] @staticmethod def amplitude_damping(gamma: float = 0.001, device: str = "cuda") -> "NoiseModel": """ Amplitude damping (T1 relaxation): |1⟩ → |0⟩ decay Kraus operators: K₀ = [[1, 0], [0, √(1-γ)]] K₁ = [[0, √γ], [0, 0]] """ model = NoiseModel() sqrt_gamma = np.sqrt(gamma) sqrt_1_gamma = np.sqrt(1 - gamma) K0 = torch.tensor([[1, 0], [0, sqrt_1_gamma]], dtype=torch.complex64, device=device) K1 = torch.tensor([[0, sqrt_gamma], [0, 0]], dtype=torch.complex64, device=device) kraus = [K0, K1] model.add_1q_channel( "amplitude_damping", NoiseChannel("amplitude_damping", kraus, num_qubits=1) ) return model
@staticmethod def pauli_channel(px: float, py: float, pz: float, device: str = "cuda") -> "NoiseModel": """ Pauli channel: apply X/Y/Z with probabilities px/py/pz Kraus operators: K₀ = √(1-px-py-pz) I K₁ = √px X K₂ = √py Y K₃ = √pz Z """ model = NoiseModel() p_total = px + py + pz assert p_total <= 1.0, f"Total probability {p_total} > 1" I = torch.eye(2, dtype=torch.complex64, device=device) X = torch.tensor([[0, 1], [1, 0]], dtype=torch.complex64, device=device) Y = torch.tensor([[0, -1j], [1j, 0]], dtype=torch.complex64, device=device) Z = torch.tensor([[1, 0], [0, -1]], dtype=torch.complex64, device=device) kraus = [np.sqrt(1 - p_total) * I, np.sqrt(px) * X, np.sqrt(py) * Y, np.sqrt(pz) * Z] model.add_1q_channel("pauli", NoiseChannel("pauli", kraus, num_qubits=1)) return model
[docs] @staticmethod def thermal_relaxation( t1: float, t2: float, gate_time: float, device: str = "cuda" ) -> "NoiseModel": """ Thermal relaxation combining T1 (amplitude) and T2 (phase) damping Args: t1: T1 relaxation time (microseconds) t2: T2 dephasing time (microseconds) gate_time: Gate duration (microseconds) Approximated as amplitude damping + pure dephasing """ assert t2 <= 2 * t1, "T2 must satisfy T2 ≤ 2T1" # Compute probabilities gamma = 1 - np.exp(-gate_time / t1) # Amplitude damping p_phase = 0.5 * (1 - np.exp(-gate_time / t2) - gamma) # Pure dephasing model = NoiseModel() # Combine amplitude damping + pure dephasing # For simplicity, use sequential channels (approximate) amp_model = NoiseModel.amplitude_damping(gamma, device) deph_model = NoiseModel.dephasing(p_phase, device) # Combine channels for name, channel in amp_model.channels_1q.items(): model.add_1q_channel(name, channel) return model
class StochasticNoiseApplicator: """ Applies noise channels stochastically during MPS simulation Usage: applicator = StochasticNoiseApplicator(noise_model, seed=42) # After each gate: applicator.apply_1q_noise(mps, qubit_idx) applicator.apply_2q_noise(mps, qubit_i, qubit_j) """ def __init__(self, noise_model: NoiseModel, seed: Optional[int] = None): self.noise_model = noise_model self.rng = np.random.RandomState(seed) self.fidelity_tracker = [] def apply_1q_noise(self, mps, qubit: int): """ Apply 1-qubit noise channel by stochastically sampling Kraus operator Args: mps: AdaptiveMPS instance qubit: Target qubit index """ if not self.noise_model.channels_1q: return # For now, apply the first registered 1q channel channel = next(iter(self.noise_model.channels_1q.values())) # Sample a Kraus operator based on probabilities # P(Kᵢ) = Tr(Kᵢ ρ Kᵢ†) but we approximate with |Kᵢ|²_F / Σ|Kⱼ|²_F kraus_weights = torch.tensor([torch.norm(K).item() ** 2 for K in channel.kraus_ops]) kraus_weights /= kraus_weights.sum() # Sample one Kraus operator idx = self.rng.choice(len(channel.kraus_ops), p=kraus_weights.cpu().numpy()) K = channel.kraus_ops[idx] # Apply as a gate (non-unitary) # Note: This doesn't preserve norm exactly - need to renormalize mps.apply_single_qubit_gate(qubit, K) # Track approximate fidelity loss # F ≈ 1 - ε where ε is the noise strength # This is approximate - true fidelity tracking requires density matrices def apply_2q_noise(self, mps, qubit_i: int, qubit_j: int): """ Apply 2-qubit noise channel Args: mps: AdaptiveMPS instance qubit_i: First qubit qubit_j: Second qubit (must be adjacent for MPS) """ if not self.noise_model.channels_2q: return assert abs(qubit_i - qubit_j) == 1, "2-qubit noise requires adjacent qubits for MPS" channel = next(iter(self.noise_model.channels_2q.values())) # Sample Kraus operator kraus_weights = torch.tensor([torch.norm(K).item() ** 2 for K in channel.kraus_ops]) kraus_weights /= kraus_weights.sum() idx = self.rng.choice(len(channel.kraus_ops), p=kraus_weights.cpu().numpy()) K = channel.kraus_ops[idx] # Apply as 2-site gate bond_idx = min(qubit_i, qubit_j) mps.apply_two_site_gate(bond_idx, K) def get_fidelity_estimate(self) -> float: """ Get estimated fidelity (approximate) True fidelity requires density matrix simulation This gives a rough lower bound based on truncation + noise """ if not self.fidelity_tracker: return 1.0 # Multiply all fidelity factors (independent noise assumption) return float(np.prod(self.fidelity_tracker)) def kraus_to_choi(kraus_ops: List[torch.Tensor]) -> torch.Tensor: """ Convert Kraus representation to Choi matrix representation Choi matrix: Λ = Σᵢ |Kᵢ⟩⟩⟨⟨Kᵢ| where |K⟩⟩ = vec(K) Args: kraus_ops: List of Kraus operators Returns: Choi matrix (d² × d²) """ d = kraus_ops[0].shape[0] choi = torch.zeros(d * d, d * d, dtype=kraus_ops[0].dtype, device=kraus_ops[0].device) for K in kraus_ops: K_vec = K.reshape(-1, 1) # Vectorize choi += K_vec @ K_vec.conj().T return choi def choi_to_kraus(choi: torch.Tensor, rank_tol: float = 1e-10) -> List[torch.Tensor]: """ Convert Choi matrix to Kraus representation via eigendecomposition Args: choi: Choi matrix (d² × d²) rank_tol: Tolerance for truncating small eigenvalues Returns: List of Kraus operators """ # Eigendecomposition evals, evecs = torch.linalg.eigh(choi) # Keep positive eigenvalues above threshold mask = evals > rank_tol evals = evals[mask] evecs = evecs[:, mask] # Construct Kraus operators: Kᵢ = √λᵢ unvec(vᵢ) d = int(np.sqrt(choi.shape[0])) kraus_ops = [] for i in range(len(evals)): K_vec = torch.sqrt(evals[i]) * evecs[:, i] K = K_vec.reshape(d, d) kraus_ops.append(K) return kraus_ops