From a4b6e1a2f56fcc5cca75ad9e7ecc5151f986e50d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20P=2E=20Moutinho?= Date: Thu, 8 Aug 2024 10:12:51 +0200 Subject: [PATCH] [Refact] Module reorganization (#253) --- docs/dropout.md | 2 +- docs/fitting_a_function.md | 4 +- docs/noise.md | 2 +- docs/pde.md | 4 +- pyqtorch/__init__.py | 35 +- pyqtorch/api.py | 2 +- pyqtorch/circuit.py | 253 +------------ pyqtorch/composite/__init__.py | 4 + pyqtorch/composite/compose.py | 282 +++++++++++++++ pyqtorch/composite/sequence.py | 163 +++++++++ pyqtorch/differentiation/adjoint.py | 6 +- pyqtorch/differentiation/gpsr.py | 7 +- pyqtorch/hamiltonians/__init__.py | 4 + .../{analog.py => hamiltonians/evolution.py} | 205 +---------- pyqtorch/hamiltonians/observable.py | 49 +++ pyqtorch/noise/__init__.py | 13 + pyqtorch/{noise.py => noise/gates.py} | 52 --- pyqtorch/noise/protocol.py | 54 +++ pyqtorch/primitives/__init__.py | 41 +++ pyqtorch/primitives/parametric.py | 333 ++++++++++++++++++ .../parametric_gates.py} | 325 +---------------- pyqtorch/primitives/primitive.py | 92 +++++ .../primitive_gates.py} | 93 +---- .../{quantum_ops.py => quantum_operation.py} | 0 pyqtorch/utils.py | 2 +- tests/conftest.py | 14 +- tests/helpers.py | 13 +- tests/test_analog.py | 2 +- tests/test_circuit.py | 2 +- tests/test_differentiation.py | 2 +- tests/test_digital.py | 5 +- tests/test_embedding.py | 2 +- tests/test_gpsr.py | 4 +- tests/test_noise.py | 13 +- tests/test_tensor.py | 9 +- 35 files changed, 1116 insertions(+), 977 deletions(-) create mode 100644 pyqtorch/composite/__init__.py create mode 100644 pyqtorch/composite/compose.py create mode 100644 pyqtorch/composite/sequence.py create mode 100644 pyqtorch/hamiltonians/__init__.py rename pyqtorch/{analog.py => hamiltonians/evolution.py} (65%) create mode 100644 pyqtorch/hamiltonians/observable.py create mode 100644 pyqtorch/noise/__init__.py rename pyqtorch/{noise.py => noise/gates.py} (88%) create mode 100644 pyqtorch/noise/protocol.py create mode 100644 pyqtorch/primitives/__init__.py create mode 100644 pyqtorch/primitives/parametric.py rename pyqtorch/{parametric.py => primitives/parametric_gates.py} (63%) create mode 100644 pyqtorch/primitives/primitive.py rename pyqtorch/{primitive.py => primitives/primitive_gates.py} (64%) rename pyqtorch/{quantum_ops.py => quantum_operation.py} (100%) diff --git a/docs/dropout.md b/docs/dropout.md index f68be224..1b2f4210 100644 --- a/docs/dropout.md +++ b/docs/dropout.md @@ -15,7 +15,7 @@ from torch import manual_seed, optim, tensor import pyqtorch as pyq from pyqtorch.circuit import DropoutQuantumCircuit -from pyqtorch.parametric import Parametric +from pyqtorch.primitives import Parametric from pyqtorch.utils import DropoutMode seed = 70 diff --git a/docs/fitting_a_function.md b/docs/fitting_a_function.md index e42c98b4..42704782 100644 --- a/docs/fitting_a_function.md +++ b/docs/fitting_a_function.md @@ -7,9 +7,9 @@ from operator import add from functools import reduce import torch import pyqtorch as pyq -from pyqtorch.circuit import hea +from pyqtorch.composite import hea from pyqtorch.utils import DiffMode -from pyqtorch.parametric import Parametric +from pyqtorch.primitives import Parametric import matplotlib.pyplot as plt from torch.nn.functional import mse_loss diff --git a/docs/noise.md b/docs/noise.md index 631c5f37..1f4aa84a 100644 --- a/docs/noise.md +++ b/docs/noise.md @@ -116,7 +116,7 @@ import torch from pyqtorch.circuit import QuantumCircuit from pyqtorch.noise import BitFlip -from pyqtorch.primitive import X +from pyqtorch.primitives import X from pyqtorch.utils import product_state diff --git a/docs/pde.md b/docs/pde.md index 5941cb78..e87ba627 100644 --- a/docs/pde.md +++ b/docs/pde.md @@ -13,9 +13,9 @@ import numpy as np import torch from torch import Tensor, exp, linspace, ones_like, optim, rand, sin, tensor from torch.autograd import grad -from pyqtorch.circuit import hea +from pyqtorch.composite import hea from pyqtorch import CNOT, RX, RY, QuantumCircuit, Z, expectation, Sequence, Merge, Add, Observable -from pyqtorch.parametric import Parametric +from pyqtorch.primitives import Parametric from pyqtorch.utils import DiffMode DIFF_MODE = DiffMode.AD diff --git a/pyqtorch/__init__.py b/pyqtorch/__init__.py index 01fd000e..5c47de2f 100644 --- a/pyqtorch/__init__.py +++ b/pyqtorch/__init__.py @@ -46,16 +46,17 @@ logger.info(f"PyQTorch logger successfully setup with log level {LOG_LEVEL}") -from .analog import ( +from .api import expectation, run, sample +from .apply import apply_operator +from .circuit import DropoutQuantumCircuit, QuantumCircuit +from .composite import ( Add, - HamiltonianEvolution, - Observable, + Merge, Scale, + Sequence, ) -from .api import expectation, run, sample -from .apply import apply_operator -from .circuit import DropoutQuantumCircuit, Merge, QuantumCircuit, Sequence from .embed import ConcretizedCallable, Embedding +from .hamiltonians import HamiltonianEvolution, Observable from .noise import ( AmplitudeDamping, BitFlip, @@ -65,22 +66,12 @@ PhaseDamping, PhaseFlip, ) -from .parametric import ( +from .primitives import ( + CNOT, CPHASE, CRX, CRY, CRZ, - OPS_PARAM, - OPS_PARAM_1Q, - OPS_PARAM_2Q, - PHASE, - RX, - RY, - RZ, - U, -) -from .primitive import ( - CNOT, CSWAP, CY, CZ, @@ -88,7 +79,14 @@ OPS_2Q, OPS_3Q, OPS_DIGITAL, + OPS_PARAM, + OPS_PARAM_1Q, + OPS_PARAM_2Q, OPS_PAULI, + PHASE, + RX, + RY, + RZ, SWAP, H, I, @@ -98,6 +96,7 @@ SDagger, T, Toffoli, + U, X, Y, Z, diff --git a/pyqtorch/api.py b/pyqtorch/api.py index 1183edeb..124e7158 100644 --- a/pyqtorch/api.py +++ b/pyqtorch/api.py @@ -7,7 +7,6 @@ import torch from torch import Tensor -from pyqtorch.analog import Observable from pyqtorch.apply import apply_operator from pyqtorch.circuit import QuantumCircuit from pyqtorch.differentiation import ( @@ -16,6 +15,7 @@ check_support_psr, ) from pyqtorch.embed import Embedding +from pyqtorch.hamiltonians import Observable from pyqtorch.utils import DiffMode, sample_multinomial logger = getLogger(__name__) diff --git a/pyqtorch/circuit.py b/pyqtorch/circuit.py index d1a7b8e0..ce7f7f9d 100644 --- a/pyqtorch/circuit.py +++ b/pyqtorch/circuit.py @@ -1,24 +1,16 @@ from __future__ import annotations -import logging from collections import Counter from functools import reduce from logging import getLogger from operator import add -from typing import Any, Generator, Iterator, NoReturn import torch -from numpy import int64 -from torch import Tensor, bernoulli, complex128, einsum, rand, tensor -from torch import device as torch_device -from torch import dtype as torch_dtype -from torch.nn import Module, ModuleList, ParameterDict +from torch import Tensor, bernoulli, tensor +from torch.nn import Module, ParameterDict -from pyqtorch.apply import apply_operator +from pyqtorch.composite import Sequence from pyqtorch.embed import Embedding -from pyqtorch.matrices import _dagger, add_batch_dim -from pyqtorch.parametric import RX, RY, Parametric -from pyqtorch.primitive import CNOT, Primitive from pyqtorch.utils import ( DensityMatrix, DropoutMode, @@ -31,147 +23,6 @@ logger = getLogger(__name__) -def forward_hook(*args, **kwargs) -> None: # type: ignore[no-untyped-def] - logger.debug("Forward complete") - torch.cuda.nvtx.range_pop() - - -def pre_forward_hook(*args, **kwargs) -> None: # type: ignore[no-untyped-def] - logger.debug("Executing forward") - torch.cuda.nvtx.range_push("QuantumCircuit.forward") - - -def backward_hook(*args, **kwargs) -> None: # type: ignore[no-untyped-def] - logger.debug("Backward complete") - torch.cuda.nvtx.range_pop() - - -def pre_backward_hook(*args, **kwargs) -> None: # type: ignore[no-untyped-def] - logger.debug("Executed backward") - torch.cuda.nvtx.range_push("QuantumCircuit.backward") - - -class Sequence(Module): - """A generic container for pyqtorch operations""" - - def __init__(self, operations: list[Module]): - super().__init__() - self.operations = ModuleList(operations) - self._device = torch_device("cpu") - self._dtype = complex128 - if len(self.operations) > 0: - try: - self._device = next(iter(set((op.device for op in self.operations)))) - except StopIteration: - pass - logger.debug("QuantumCircuit initialized") - if logger.isEnabledFor(logging.DEBUG): - # When Debugging let's add logging and NVTX markers - # WARNING: incurs performance penalty - self.register_forward_hook(forward_hook, always_call=True) - self.register_full_backward_hook(backward_hook) - self.register_forward_pre_hook(pre_forward_hook) - self.register_full_backward_pre_hook(pre_backward_hook) - - self._qubit_support = tuple( - set( - sum( - [op.qubit_support for op in self.operations], - (), - ) - ) - ) - - self._qubit_support = tuple( - map( - lambda x: x if isinstance(x, (int, int64)) else x[0], - self._qubit_support, - ) - ) - assert all( - [isinstance(q, (int, int64)) for q in self._qubit_support] - ) # TODO fix numpy.int issue - - @property - def qubit_support(self) -> tuple: - return self._qubit_support - - def __iter__(self) -> Iterator: - return iter(self.operations) - - def __len__(self) -> int: - return len(self.operations) - - def __hash__(self) -> int: - return hash(reduce(add, (hash(op) for op in self.operations))) - - def forward( - self, - state: Tensor, - values: dict[str, Tensor] | ParameterDict = dict(), - embedding: Embedding | None = None, - ) -> State: - for op in self.operations: - state = op(state, values, embedding) - return state - - @property - def device(self) -> torch_device: - return self._device - - @property - def dtype(self) -> torch_dtype: - return self._dtype - - def to(self, *args: Any, **kwargs: Any) -> Sequence: - self.operations = ModuleList([op.to(*args, **kwargs) for op in self.operations]) - if len(self.operations) > 0: - self._device = self.operations[0].device - self._dtype = self.operations[0].dtype - return self - - def flatten(self) -> ModuleList: - ops = [] - for op in self.operations: - if isinstance(op, Sequence): - ops += op.flatten() - else: - ops.append(op) - return ModuleList(ops) - - def tensor( - self, - values: dict[str, Tensor] = dict(), - embedding: Embedding | None = None, - full_support: tuple[int, ...] | None = None, - ) -> Tensor: - if full_support is None: - full_support = self.qubit_support - elif not set(self.qubit_support).issubset(set(full_support)): - raise ValueError( - "Expanding tensor operation requires a `full_support` argument " - "larger than or equal to the `qubit_support`." - ) - mat = torch.eye( - 2 ** len(full_support), dtype=self.dtype, device=self.device - ).unsqueeze(2) - return reduce( - lambda t0, t1: einsum("ijb,jkb->ikb", t1, t0), - ( - add_batch_dim(op.tensor(values, embedding, full_support)) - for op in self.operations - ), - mat, - ) - - def dagger( - self, - values: dict[str, Tensor] | Tensor = dict(), - embedding: Embedding | None = None, - ) -> Tensor: - return _dagger(self.tensor(values, embedding)) - - class QuantumCircuit(Sequence): """A QuantumCircuit defining a register / number of qubits of the full system.""" @@ -340,101 +191,3 @@ def canonical_fwd_dropout( state = op(state, values) return state - - -class Merge(Sequence): - def __init__( - self, - operations: list[Module], - ): - """ - Merge a sequence of single qubit operations acting on the same qubit into a single - einsum operation. - - Arguments: - operations: A list of single qubit operations. - - """ - - if ( - isinstance(operations, (list, ModuleList)) - and all([isinstance(op, (Primitive, Parametric)) for op in operations]) - and all(list([len(op.qubit_support) == 1 for op in operations])) - and len(list(set([op.qubit_support[0] for op in operations]))) == 1 - ): - # We want all operations to act on the same qubit - - super().__init__(operations) - self.qubits = operations[0].qubit_support - else: - raise TypeError( - f"Require all operations to act on a single qubit. Got: {operations}." - ) - - def forward( - self, - state: Tensor, - values: dict[str, Tensor] = dict(), - embedding: Embedding | None = None, - ) -> Tensor: - batch_size = state.shape[-1] - if values: - batch_size = max( - batch_size, - max( - list( - map( - lambda t: len(t) if isinstance(t, Tensor) else 1, - values.values(), - ) - ) - ), - ) - return apply_operator( - state, - add_batch_dim(self.tensor(values, embedding), batch_size), - self.qubits, - ) - - def tensor( - self, - values: dict[str, Tensor] = dict(), - embedding: Embedding | None = None, - full_support: tuple[int, ...] | None = None, - ) -> Tensor: - # We reverse the list of tensors here since matmul is not commutative. - return reduce( - lambda u0, u1: einsum("ijb,jkb->ikb", u0, u1), - ( - op.tensor(values, embedding, full_support) - for op in reversed(self.operations) - ), - ) - - -def hea(n_qubits: int, depth: int, param_name: str) -> tuple[ModuleList, ParameterDict]: - def _idx() -> Generator[int, Any, NoReturn]: - i = 0 - while True: - yield i - i += 1 - - def idxer() -> Generator[int, Any, None]: - yield from _idx() - - idx = idxer() - ops = [] - for _ in range(depth): - layer = [] - for i in range(n_qubits): - layer += [ - Merge([fn(i, f"{param_name}_{next(idx)}") for fn in [RX, RY, RX]]) - ] - ops += layer - ops += [ - Sequence([CNOT(i % n_qubits, (i + 1) % n_qubits) for i in range(n_qubits)]) - ] - params = ParameterDict( - {f"{param_name}_{n}": rand(1, requires_grad=True) for n in range(next(idx))} - ) - return ops, params diff --git a/pyqtorch/composite/__init__.py b/pyqtorch/composite/__init__.py new file mode 100644 index 00000000..6a66a230 --- /dev/null +++ b/pyqtorch/composite/__init__.py @@ -0,0 +1,4 @@ +from __future__ import annotations + +from .compose import Add, Merge, Scale, hea +from .sequence import Sequence diff --git a/pyqtorch/composite/compose.py b/pyqtorch/composite/compose.py new file mode 100644 index 00000000..4e2a5ade --- /dev/null +++ b/pyqtorch/composite/compose.py @@ -0,0 +1,282 @@ +from __future__ import annotations + +from functools import reduce +from logging import getLogger +from operator import add +from typing import Any, Generator, NoReturn, Union + +import torch +from torch import Tensor, einsum, rand +from torch.nn import Module, ModuleList, ParameterDict + +from pyqtorch.apply import apply_operator +from pyqtorch.embed import Embedding +from pyqtorch.matrices import add_batch_dim +from pyqtorch.primitives import CNOT, RX, RY, Parametric, Primitive +from pyqtorch.utils import ( + Operator, + State, +) + +from .sequence import Sequence + +BATCH_DIM = 2 + +logger = getLogger(__name__) + + +class Scale(Sequence): + """ + Generic container for multiplying a 'Primitive', 'Sequence' or 'Add' instance by a parameter. + + Attributes: + operations: Operations as a Sequence, Add, or a single Primitive operation. + param_name: Name of the parameter to multiply operations with. + """ + + def __init__( + self, operations: Union[Primitive, Sequence, Add], param_name: str | Tensor + ): + """ + Initializes a Scale object. + + Arguments: + operations: Operations as a Sequence, Add, or a single Primitive operation. + param_name: Name of the parameter to multiply operations with. + """ + if not isinstance(operations, (Primitive, Sequence, Add)): + raise ValueError("Scale only supports a single operation, Sequence or Add.") + super().__init__([operations]) + self.param_name = param_name + + def forward( + self, + state: Tensor, + values: dict[str, Tensor] | ParameterDict = dict(), + embedding: Embedding | None = None, + ) -> State: + """ + Apply the operation(s) multiplying by the parameter value. + + Arguments: + state: Input state. + values: Parameter value. + + Returns: + The transformed state. + """ + + scale = ( + values[self.param_name] + if isinstance(self.param_name, str) + else self.param_name + ) + return scale * self.operations[0].forward(state, values, embedding) + + def tensor( + self, + values: dict[str, Tensor] = dict(), + embedding: Embedding | None = None, + full_support: tuple[int, ...] | None = None, + ) -> Operator: + """ + Get the corresponding unitary over n_qubits. + + Arguments: + values: Parameter value. + embedding: An optional embedding. + full_support: Can be higher than the number of qubit support. + + Returns: + The unitary representation. + """ + scale = ( + values[self.param_name] + if isinstance(self.param_name, str) + else self.param_name + ) + return scale * self.operations[0].tensor(values, embedding, full_support) + + def flatten(self) -> list[Scale]: + """This method should only be called in the AdjointExpectation, + where the `Scale` is only supported for Primitive (and not Sequences) + so we don't want to flatten this to preserve the scale parameter. + + Returns: + The Scale within a list. + """ + return [self] + + def to(self, *args: Any, **kwargs: Any) -> Scale: + """Perform conversions for dtype or device. + + Returns: + Converted Scale. + """ + super().to(*args, **kwargs) + if not isinstance(self.param_name, str): + self.param_name = self.param_name.to(*args, **kwargs) + + return self + + +class Add(Sequence): + """ + The 'add' operation applies all 'operations' to 'state' and returns the sum of states. + + Attributes: + operations: List of operations to add up. + """ + + def __init__(self, operations: list[Module]): + + super().__init__(operations=operations) + + def forward( + self, + state: State, + values: dict[str, Tensor] | ParameterDict = dict(), + embedding: Embedding | None = None, + ) -> State: + """ + Apply the operations multiplying by the parameter values. + + Arguments: + state: Input state. + values: Parameter value. + + Returns: + The transformed state. + """ + return reduce(add, (op(state, values, embedding) for op in self.operations)) + + def tensor( + self, + values: dict = dict(), + embedding: Embedding | None = None, + full_support: tuple[int, ...] | None = None, + ) -> Tensor: + """ + Get the corresponding sum of unitaries over n_qubits. + + Arguments: + values: Parameter value. + Can be higher than the number of qubit support. + + + Returns: + The unitary representation. + """ + if full_support is None: + full_support = self.qubit_support + elif not set(self.qubit_support).issubset(set(full_support)): + raise ValueError( + "Expanding tensor operation requires a `full_support` argument " + "larger than or equal to the `qubit_support`." + ) + mat = torch.zeros( + (2 ** len(full_support), 2 ** len(full_support), 1), device=self.device + ) + return reduce( + add, + (op.tensor(values, embedding, full_support) for op in self.operations), + mat, + ) + + +class Merge(Sequence): + def __init__( + self, + operations: list[Module], + ): + """ + Merge a sequence of single qubit operations acting on the same qubit into a single + einsum operation. + + Arguments: + operations: A list of single qubit operations. + + """ + + if ( + isinstance(operations, (list, ModuleList)) + and all([isinstance(op, (Primitive, Parametric)) for op in operations]) + and all(list([len(op.qubit_support) == 1 for op in operations])) + and len(list(set([op.qubit_support[0] for op in operations]))) == 1 + ): + # We want all operations to act on the same qubit + + super().__init__(operations) + self.qubits = operations[0].qubit_support + else: + raise TypeError( + f"Require all operations to act on a single qubit. Got: {operations}." + ) + + def forward( + self, + state: Tensor, + values: dict[str, Tensor] = dict(), + embedding: Embedding | None = None, + ) -> Tensor: + batch_size = state.shape[-1] + if values: + batch_size = max( + batch_size, + max( + list( + map( + lambda t: len(t) if isinstance(t, Tensor) else 1, + values.values(), + ) + ) + ), + ) + return apply_operator( + state, + add_batch_dim(self.tensor(values, embedding), batch_size), + self.qubits, + ) + + def tensor( + self, + values: dict[str, Tensor] = dict(), + embedding: Embedding | None = None, + full_support: tuple[int, ...] | None = None, + ) -> Tensor: + # We reverse the list of tensors here since matmul is not commutative. + return reduce( + lambda u0, u1: einsum("ijb,jkb->ikb", u0, u1), + ( + op.tensor(values, embedding, full_support) + for op in reversed(self.operations) + ), + ) + + +def hea(n_qubits: int, depth: int, param_name: str) -> tuple[ModuleList, ParameterDict]: + def _idx() -> Generator[int, Any, NoReturn]: + i = 0 + while True: + yield i + i += 1 + + def idxer() -> Generator[int, Any, None]: + yield from _idx() + + idx = idxer() + ops = [] + for _ in range(depth): + layer = [] + for i in range(n_qubits): + layer += [ + Merge([fn(i, f"{param_name}_{next(idx)}") for fn in [RX, RY, RX]]) + ] + ops += layer + ops += [ + Sequence([CNOT(i % n_qubits, (i + 1) % n_qubits) for i in range(n_qubits)]) + ] + params = ParameterDict( + {f"{param_name}_{n}": rand(1, requires_grad=True) for n in range(next(idx))} + ) + return ops, params diff --git a/pyqtorch/composite/sequence.py b/pyqtorch/composite/sequence.py new file mode 100644 index 00000000..97174300 --- /dev/null +++ b/pyqtorch/composite/sequence.py @@ -0,0 +1,163 @@ +from __future__ import annotations + +import logging +from functools import reduce +from logging import getLogger +from operator import add +from typing import Any, Iterator + +import torch +from numpy import int64 +from torch import Tensor, complex128, einsum +from torch import device as torch_device +from torch import dtype as torch_dtype +from torch.nn import Module, ModuleList, ParameterDict + +from pyqtorch.embed import Embedding +from pyqtorch.matrices import _dagger, add_batch_dim +from pyqtorch.utils import ( + State, +) + +logger = getLogger(__name__) + + +def forward_hook(*args, **kwargs) -> None: # type: ignore[no-untyped-def] + logger.debug("Forward complete") + torch.cuda.nvtx.range_pop() + + +def pre_forward_hook(*args, **kwargs) -> None: # type: ignore[no-untyped-def] + logger.debug("Executing forward") + torch.cuda.nvtx.range_push("QuantumCircuit.forward") + + +def backward_hook(*args, **kwargs) -> None: # type: ignore[no-untyped-def] + logger.debug("Backward complete") + torch.cuda.nvtx.range_pop() + + +def pre_backward_hook(*args, **kwargs) -> None: # type: ignore[no-untyped-def] + logger.debug("Executed backward") + torch.cuda.nvtx.range_push("QuantumCircuit.backward") + + +class Sequence(Module): + """A generic container for pyqtorch operations""" + + def __init__(self, operations: list[Module]): + super().__init__() + self.operations = ModuleList(operations) + self._device = torch_device("cpu") + self._dtype = complex128 + if len(self.operations) > 0: + try: + self._device = next(iter(set((op.device for op in self.operations)))) + except StopIteration: + pass + logger.debug("QuantumCircuit initialized") + if logger.isEnabledFor(logging.DEBUG): + # When Debugging let's add logging and NVTX markers + # WARNING: incurs performance penalty + self.register_forward_hook(forward_hook, always_call=True) + self.register_full_backward_hook(backward_hook) + self.register_forward_pre_hook(pre_forward_hook) + self.register_full_backward_pre_hook(pre_backward_hook) + + self._qubit_support = tuple( + set( + sum( + [op.qubit_support for op in self.operations], + (), + ) + ) + ) + + self._qubit_support = tuple( + map( + lambda x: x if isinstance(x, (int, int64)) else x[0], + self._qubit_support, + ) + ) + assert all( + [isinstance(q, (int, int64)) for q in self._qubit_support] + ) # TODO fix numpy.int issue + + @property + def qubit_support(self) -> tuple: + return self._qubit_support + + def __iter__(self) -> Iterator: + return iter(self.operations) + + def __len__(self) -> int: + return len(self.operations) + + def __hash__(self) -> int: + return hash(reduce(add, (hash(op) for op in self.operations))) + + def forward( + self, + state: Tensor, + values: dict[str, Tensor] | ParameterDict = dict(), + embedding: Embedding | None = None, + ) -> State: + for op in self.operations: + state = op(state, values, embedding) + return state + + @property + def device(self) -> torch_device: + return self._device + + @property + def dtype(self) -> torch_dtype: + return self._dtype + + def to(self, *args: Any, **kwargs: Any) -> Sequence: + self.operations = ModuleList([op.to(*args, **kwargs) for op in self.operations]) + if len(self.operations) > 0: + self._device = self.operations[0].device + self._dtype = self.operations[0].dtype + return self + + def flatten(self) -> ModuleList: + ops = [] + for op in self.operations: + if isinstance(op, Sequence): + ops += op.flatten() + else: + ops.append(op) + return ModuleList(ops) + + def tensor( + self, + values: dict[str, Tensor] = dict(), + embedding: Embedding | None = None, + full_support: tuple[int, ...] | None = None, + ) -> Tensor: + if full_support is None: + full_support = self.qubit_support + elif not set(self.qubit_support).issubset(set(full_support)): + raise ValueError( + "Expanding tensor operation requires a `full_support` argument " + "larger than or equal to the `qubit_support`." + ) + mat = torch.eye( + 2 ** len(full_support), dtype=self.dtype, device=self.device + ).unsqueeze(2) + return reduce( + lambda t0, t1: einsum("ijb,jkb->ikb", t1, t0), + ( + add_batch_dim(op.tensor(values, embedding, full_support)) + for op in self.operations + ), + mat, + ) + + def dagger( + self, + values: dict[str, Tensor] | Tensor = dict(), + embedding: Embedding | None = None, + ) -> Tensor: + return _dagger(self.tensor(values, embedding)) diff --git a/pyqtorch/differentiation/adjoint.py b/pyqtorch/differentiation/adjoint.py index a8c42848..1da56896 100644 --- a/pyqtorch/differentiation/adjoint.py +++ b/pyqtorch/differentiation/adjoint.py @@ -6,12 +6,12 @@ from torch import Tensor, no_grad from torch.autograd import Function -from pyqtorch.analog import Observable, Scale from pyqtorch.apply import apply_operator from pyqtorch.circuit import QuantumCircuit +from pyqtorch.composite import Scale from pyqtorch.embed import Embedding -from pyqtorch.parametric import Parametric -from pyqtorch.primitive import Primitive +from pyqtorch.hamiltonians import Observable +from pyqtorch.primitives import Parametric, Primitive from pyqtorch.utils import inner_prod, param_dict logger = getLogger(__name__) diff --git a/pyqtorch/differentiation/gpsr.py b/pyqtorch/differentiation/gpsr.py index fc755865..e784c748 100644 --- a/pyqtorch/differentiation/gpsr.py +++ b/pyqtorch/differentiation/gpsr.py @@ -7,11 +7,12 @@ from torch import Tensor, no_grad from torch.autograd import Function -from pyqtorch.analog import HamiltonianEvolution, Observable, Scale -from pyqtorch.circuit import QuantumCircuit, Sequence +from pyqtorch.circuit import QuantumCircuit +from pyqtorch.composite import Scale, Sequence from pyqtorch.embed import Embedding +from pyqtorch.hamiltonians import HamiltonianEvolution, Observable from pyqtorch.matrices import DEFAULT_REAL_DTYPE -from pyqtorch.parametric import Parametric +from pyqtorch.primitives import Parametric from pyqtorch.utils import param_dict logger = getLogger(__name__) diff --git a/pyqtorch/hamiltonians/__init__.py b/pyqtorch/hamiltonians/__init__.py new file mode 100644 index 00000000..32f77688 --- /dev/null +++ b/pyqtorch/hamiltonians/__init__.py @@ -0,0 +1,4 @@ +from __future__ import annotations + +from .evolution import GeneratorType, HamiltonianEvolution +from .observable import Observable diff --git a/pyqtorch/analog.py b/pyqtorch/hamiltonians/evolution.py similarity index 65% rename from pyqtorch/analog.py rename to pyqtorch/hamiltonians/evolution.py index fb9e680f..9e0e3019 100644 --- a/pyqtorch/analog.py +++ b/pyqtorch/hamiltonians/evolution.py @@ -2,26 +2,23 @@ import logging from collections import OrderedDict -from functools import reduce from logging import getLogger -from operator import add -from typing import Any, Callable, Tuple, Union +from typing import Callable, Tuple, Union import torch from torch import Tensor -from torch.nn import Module, ModuleList, ParameterDict +from torch.nn import ModuleList, ParameterDict from pyqtorch.apply import apply_operator from pyqtorch.circuit import Sequence from pyqtorch.embed import Embedding -from pyqtorch.primitive import Primitive +from pyqtorch.primitives import Primitive from pyqtorch.utils import ( ATOL, Operator, State, StrEnum, expand_operator, - inner_prod, is_diag, ) @@ -68,202 +65,6 @@ class GeneratorType(StrEnum): """Generators which are symbolic, i.e. will be passed via the 'values' dict by the user.""" -class Scale(Sequence): - """ - Generic container for multiplying a 'Primitive', 'Sequence' or 'Add' instance by a parameter. - - Attributes: - operations: Operations as a Sequence, Add, or a single Primitive operation. - param_name: Name of the parameter to multiply operations with. - """ - - def __init__( - self, operations: Union[Primitive, Sequence, Add], param_name: str | Tensor - ): - """ - Initializes a Scale object. - - Arguments: - operations: Operations as a Sequence, Add, or a single Primitive operation. - param_name: Name of the parameter to multiply operations with. - """ - if not isinstance(operations, (Primitive, Sequence, Add)): - raise ValueError("Scale only supports a single operation, Sequence or Add.") - super().__init__([operations]) - self.param_name = param_name - - def forward( - self, - state: Tensor, - values: dict[str, Tensor] | ParameterDict = dict(), - embedding: Embedding | None = None, - ) -> State: - """ - Apply the operation(s) multiplying by the parameter value. - - Arguments: - state: Input state. - values: Parameter value. - - Returns: - The transformed state. - """ - - scale = ( - values[self.param_name] - if isinstance(self.param_name, str) - else self.param_name - ) - return scale * self.operations[0].forward(state, values, embedding) - - def tensor( - self, - values: dict[str, Tensor] = dict(), - embedding: Embedding | None = None, - full_support: tuple[int, ...] | None = None, - ) -> Operator: - """ - Get the corresponding unitary over n_qubits. - - Arguments: - values: Parameter value. - embedding: An optional embedding. - full_support: Can be higher than the number of qubit support. - - Returns: - The unitary representation. - """ - scale = ( - values[self.param_name] - if isinstance(self.param_name, str) - else self.param_name - ) - return scale * self.operations[0].tensor(values, embedding, full_support) - - def flatten(self) -> list[Scale]: - """This method should only be called in the AdjointExpectation, - where the `Scale` is only supported for Primitive (and not Sequences) - so we don't want to flatten this to preserve the scale parameter. - - Returns: - The Scale within a list. - """ - return [self] - - def to(self, *args: Any, **kwargs: Any) -> Scale: - """Perform conversions for dtype or device. - - Returns: - Converted Scale. - """ - super().to(*args, **kwargs) - if not isinstance(self.param_name, str): - self.param_name = self.param_name.to(*args, **kwargs) - - return self - - -class Add(Sequence): - """ - The 'add' operation applies all 'operations' to 'state' and returns the sum of states. - - Attributes: - operations: List of operations to add up. - """ - - def __init__(self, operations: list[Module]): - - super().__init__(operations=operations) - - def forward( - self, - state: State, - values: dict[str, Tensor] | ParameterDict = dict(), - embedding: Embedding | None = None, - ) -> State: - """ - Apply the operations multiplying by the parameter values. - - Arguments: - state: Input state. - values: Parameter value. - - Returns: - The transformed state. - """ - return reduce(add, (op(state, values, embedding) for op in self.operations)) - - def tensor( - self, - values: dict = dict(), - embedding: Embedding | None = None, - full_support: tuple[int, ...] | None = None, - ) -> Tensor: - """ - Get the corresponding sum of unitaries over n_qubits. - - Arguments: - values: Parameter value. - Can be higher than the number of qubit support. - - - Returns: - The unitary representation. - """ - if full_support is None: - full_support = self.qubit_support - elif not set(self.qubit_support).issubset(set(full_support)): - raise ValueError( - "Expanding tensor operation requires a `full_support` argument " - "larger than or equal to the `qubit_support`." - ) - mat = torch.zeros( - (2 ** len(full_support), 2 ** len(full_support), 1), device=self.device - ) - return reduce( - add, - (op.tensor(values, embedding, full_support) for op in self.operations), - mat, - ) - - -class Observable(Add): - """ - The Observable :math:`O` represents an operator from which - we can extract expectation values from quantum states. - - Given an input state :math:`\\ket\\rangle`, the expectation value with :math:`O` is defined as - :math:`\\langle\\bra|O\\ket\\rangle` - - Attributes: - operations: List of operations. - n_qubits: Number of qubits it is defined on. - """ - - def __init__( - self, - operations: list[Module] | Primitive | Sequence, - ): - super().__init__(operations if isinstance(operations, list) else [operations]) - - def expectation( - self, - state: Tensor, - values: dict[str, Tensor] | ParameterDict = dict(), - embedding: Embedding | None = None, - ) -> Tensor: - """Calculate the inner product :math:`\\langle\\bra|O\\ket\\rangle` - - Arguments: - state: Input state. - values: Values of parameters. - - Returns: - The expectation value. - """ - return inner_prod(state, self.forward(state, values, embedding)).real - - def is_diag_hamiltonian(hamiltonian: Operator, atol: Tensor = ATOL) -> bool: """ Returns True if the batched tensors H are diagonal. diff --git a/pyqtorch/hamiltonians/observable.py b/pyqtorch/hamiltonians/observable.py new file mode 100644 index 00000000..61edd454 --- /dev/null +++ b/pyqtorch/hamiltonians/observable.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from torch import Tensor +from torch.nn import Module, ParameterDict + +from pyqtorch.circuit import Sequence +from pyqtorch.composite import Add +from pyqtorch.embed import Embedding +from pyqtorch.primitives import Primitive +from pyqtorch.utils import ( + inner_prod, +) + + +class Observable(Add): + """ + The Observable :math:`O` represents an operator from which + we can extract expectation values from quantum states. + + Given an input state :math:`\\ket\\rangle`, the expectation value with :math:`O` is defined as + :math:`\\langle\\bra|O\\ket\\rangle` + + Attributes: + operations: List of operations. + n_qubits: Number of qubits it is defined on. + """ + + def __init__( + self, + operations: list[Module] | Primitive | Sequence, + ): + super().__init__(operations if isinstance(operations, list) else [operations]) + + def expectation( + self, + state: Tensor, + values: dict[str, Tensor] | ParameterDict = dict(), + embedding: Embedding | None = None, + ) -> Tensor: + """Calculate the inner product :math:`\\langle\\bra|O\\ket\\rangle` + + Arguments: + state: Input state. + values: Values of parameters. + + Returns: + The expectation value. + """ + return inner_prod(state, self.forward(state, values, embedding)).real diff --git a/pyqtorch/noise/__init__.py b/pyqtorch/noise/__init__.py new file mode 100644 index 00000000..aa41334c --- /dev/null +++ b/pyqtorch/noise/__init__.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from .gates import ( + AmplitudeDamping, + BitFlip, + Depolarizing, + GeneralizedAmplitudeDamping, + Noise, + PauliChannel, + PhaseDamping, + PhaseFlip, +) +from .protocol import NoiseProtocol, _repr_noise diff --git a/pyqtorch/noise.py b/pyqtorch/noise/gates.py similarity index 88% rename from pyqtorch/noise.py rename to pyqtorch/noise/gates.py index e788b8df..4cb87a57 100644 --- a/pyqtorch/noise.py +++ b/pyqtorch/noise/gates.py @@ -1,6 +1,5 @@ from __future__ import annotations -import sys from math import log2, sqrt from typing import Any @@ -397,54 +396,3 @@ def __init__( ) kraus_generalized_amplitude_damping: list[Tensor] = [K0, K1, K2, K3] super().__init__(kraus_generalized_amplitude_damping, target, error_probability) - - -class NoiseProtocol: - BITFLIP = "BitFlip" - PHASEFLIP = "PhaseFlip" - DEPOLARIZING = "Depolarizing" - PAULI_CHANNEL = "PauliChannel" - AMPLITUDE_DAMPING = "AmplitudeDamping" - PHASE_DAMPING = "PhaseDamping" - GENERALIZED_AMPLITUDE_DAMPING = "GeneralizedAmplitudeDamping" - - def __init__(self, protocol: str, options: dict = dict()) -> None: - self.protocol: str = protocol - self.options: dict = options - - def __repr__(self) -> str: - if self.target: - return ( - f"{self.protocol}(prob: {self.error_probability}, " - f"target: {self.target})" - ) - return f"{self.protocol}(prob: {self.error_probability})" - - @property - def error_probability(self): - return self.options.get("error_probability") - - @property - def target(self): #! init_state not good size - return self.options.get("target") - - def protocol_to_gate(self): - try: - gate_class = getattr(sys.modules[__name__], self.protocol) - return gate_class - except AttributeError: - raise ValueError( - f"The protocol {self.protocol} has not been implemented in pyq yet." - ) - - -def _repr_noise(noise: NoiseProtocol | dict[str, NoiseProtocol] | None = None) -> str: - """Returns the string for noise representation in gates.""" - noise_info = "" - if noise is None: - return noise_info - elif isinstance(noise, NoiseProtocol): - noise_info = str(noise) - elif isinstance(noise, dict): - noise_info = ", ".join(str(noise_instance) for noise_instance in noise.values()) - return f", noise: {noise_info}" diff --git a/pyqtorch/noise/protocol.py b/pyqtorch/noise/protocol.py new file mode 100644 index 00000000..a3c3782c --- /dev/null +++ b/pyqtorch/noise/protocol.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +import sys + + +class NoiseProtocol: + BITFLIP = "BitFlip" + PHASEFLIP = "PhaseFlip" + DEPOLARIZING = "Depolarizing" + PAULI_CHANNEL = "PauliChannel" + AMPLITUDE_DAMPING = "AmplitudeDamping" + PHASE_DAMPING = "PhaseDamping" + GENERALIZED_AMPLITUDE_DAMPING = "GeneralizedAmplitudeDamping" + + def __init__(self, protocol: str, options: dict = dict()) -> None: + self.protocol: str = protocol + self.options: dict = options + + def __repr__(self) -> str: + if self.target: + return ( + f"{self.protocol}(prob: {self.error_probability}, " + f"target: {self.target})" + ) + return f"{self.protocol}(prob: {self.error_probability})" + + @property + def error_probability(self): + return self.options.get("error_probability") + + @property + def target(self): #! init_state not good size + return self.options.get("target") + + def protocol_to_gate(self): + try: + gate_class = getattr(sys.modules["pyqtorch.noise.gates"], self.protocol) + return gate_class + except AttributeError: + raise ValueError( + f"The protocol {self.protocol} has not been implemented in pyq yet." + ) + + +def _repr_noise(noise: NoiseProtocol | dict[str, NoiseProtocol] | None = None) -> str: + """Returns the string for noise representation in gates.""" + noise_info = "" + if noise is None: + return noise_info + elif isinstance(noise, NoiseProtocol): + noise_info = str(noise) + elif isinstance(noise, dict): + noise_info = ", ".join(str(noise_instance) for noise_instance in noise.values()) + return f", noise: {noise_info}" diff --git a/pyqtorch/primitives/__init__.py b/pyqtorch/primitives/__init__.py new file mode 100644 index 00000000..97ef5841 --- /dev/null +++ b/pyqtorch/primitives/__init__.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from .parametric import ControlledParametric, ControlledRotationGate, Parametric +from .parametric_gates import ( + CPHASE, + CRX, + CRY, + CRZ, + OPS_PARAM, + OPS_PARAM_1Q, + OPS_PARAM_2Q, + PHASE, + RX, + RY, + RZ, + U, +) +from .primitive import ControlledPrimitive, Primitive +from .primitive_gates import ( + CNOT, + CSWAP, + CY, + CZ, + OPS_1Q, + OPS_2Q, + OPS_3Q, + OPS_DIGITAL, + OPS_PAULI, + SWAP, + H, + I, + N, + Projector, + S, + SDagger, + T, + Toffoli, + X, + Y, + Z, +) diff --git a/pyqtorch/primitives/parametric.py b/pyqtorch/primitives/parametric.py new file mode 100644 index 00000000..157f518f --- /dev/null +++ b/pyqtorch/primitives/parametric.py @@ -0,0 +1,333 @@ +from __future__ import annotations + +from functools import cached_property +from typing import Any, Tuple + +import torch +from torch import Tensor + +from pyqtorch.embed import Embedding +from pyqtorch.matrices import ( + OPERATIONS_DICT, + _jacobian, + controlled, + parametric_unitary, +) +from pyqtorch.noise import NoiseProtocol, _repr_noise +from pyqtorch.quantum_operation import QuantumOperation, Support + +pauli_singleq_eigenvalues = torch.tensor([[-1.0], [1.0]], dtype=torch.cdouble) + + +class Parametric(QuantumOperation): + """ + QuantumOperation taking parameters as input. + + Attributes: + param_name: Name of parameters. + parse_values: Method defining how to handle the values dictionary input. + """ + + n_params = 1 + + def __init__( + self, + generator: str | Tensor, + qubit_support: int | tuple[int, ...] | Support, + param_name: str | int | float | torch.Tensor = "", + noise: NoiseProtocol | dict[str, NoiseProtocol] | None = None, + ): + """Initializes Parametric. + + Arguments: + generator: Generator to use. + qubit_support: Qubits to act on. + param_name: Name of parameters. + noise: Optional noise protocols to apply. + """ + + generator_operation = ( + OPERATIONS_DICT[generator] if isinstance(generator, str) else generator + ) + self.param_name = param_name + + def parse_values( + values: dict[str, Tensor] | Tensor = dict(), + embedding: Embedding | None = None, + ) -> Tensor: + """The legacy way of using parametric gates: + The Parametric gate received a string as a 'param_name' and performs a + a lookup in the passed `values` dict for to retrieve the torch.Tensor passed + under the key `param_name`. + + Arguments: + values: A dict containing param_name:torch.Tensor pairs + embedding: An optional embedding. + Returns: + A Torch Tensor denoting values for the `param_name`. + """ + return Parametric._expand_values(values[self.param_name]) # type: ignore[index] + + def parse_tensor( + values: dict[str, Tensor] | Tensor = dict(), + embedding: Embedding | None = None, + ) -> Tensor: + """Functional version of the Parametric gate: + In case the user did not pass a `param_name`, + pyqtorch assumes `values` will be a torch.Tensor instead of a dict. + + Arguments: + values: A dict containing param_name:torch.Tensor pairs + Returns: + A Torch Tensor with which to evaluate the Parametric Gate. + """ + # self.param_name will be "" + return Parametric._expand_values(values) + + def parse_constant( + values: dict[str, Tensor] | Tensor = dict(), + embedding: Embedding | None = None, + ) -> Tensor: + """Fix a the parameter of a Parametric Gate to a numeric constant + if the user passed a numeric input for the `param_name`. + + Arguments: + values: A dict containing param_name:torch.Tensor pairs + Returns: + A Torch Tensor with which to evaluate the Parametric Gate. + """ + # self.param_name will be a torch.Tensor + return Parametric._expand_values( + torch.tensor(self.param_name, device=self.device, dtype=self.dtype) + ) + + if param_name == "": + self.parse_values = parse_tensor + self.param_name = self.param_name + elif isinstance(param_name, str): + self.parse_values = parse_values + elif isinstance(param_name, (float, int, torch.Tensor)): + self.parse_values = parse_constant + + # Parametric is defined by generator operation and a function + # The function will use parsed parameter values to compute the unitary + super().__init__( + generator_operation, + qubit_support, + operator_function=self._construct_parametric_base_op, + noise=noise, + ) + self.register_buffer("identity", OPERATIONS_DICT["I"]) + + def extra_repr(self) -> str: + """String representation of the operation. + + Returns: + String with information on operation. + """ + return f"target: {self.qubit_support}, param: {self.param_name}" + _repr_noise( + self.noise + ) + + def __hash__(self) -> int: + """Hash qubit support and param_name + + Returns: + Hash value + """ + return hash(self.qubit_support) + hash(self.param_name) + + @staticmethod + def _expand_values(values: Tensor) -> Tensor: + """Expand values if necessary. + + Arguments: + values: Values of parameters + Returns: + Values of parameters expanded. + + """ + return values.unsqueeze(0) if len(values.size()) == 0 else values + + def _construct_parametric_base_op( + self, + values: dict[str, Tensor] | Tensor = dict(), + embedding: Embedding | None = None, + ) -> Tensor: + """ + Get the corresponding unitary with parsed values. + + Arguments: + values: A dict containing a Parameter name and value. + embedding: An optional embedding for parameters. + + Returns: + The unitary representation. + """ + thetas = self.parse_values(values, embedding) + batch_size = len(thetas) + mat = parametric_unitary(thetas, self.operation, self.identity, batch_size) + return mat + + def jacobian( + self, + values: dict[str, Tensor] | Tensor = dict(), + embedding: Embedding | None = None, + ) -> Tensor: + """ + Get the corresponding unitary of the jacobian. + + Arguments: + values: Parameter value. + + Returns: + The unitary representation of the jacobian. + """ + thetas = self.parse_values(values, embedding) + batch_size = len(thetas) + return _jacobian(thetas, self.operation, self.identity, batch_size) + + def to(self, *args: Any, **kwargs: Any) -> Parametric: + super().to(*args, **kwargs) + self._device = self.operation.device + self.param_name = ( + self.param_name.to(*args, **kwargs) + if isinstance(self.param_name, torch.Tensor) + else self.param_name + ) + self._dtype = self.operation.dtype + return self + + +class ControlledParametric(Parametric): + """ + Primitives for controlled parametric operations. + + Attributes: + control: Control qubit(s). + """ + + def __init__( + self, + operation: str | Tensor, + control: int | Tuple[int, ...], + target: int | Tuple[int, ...], + param_name: str | int | float | torch.Tensor = "", + noise: NoiseProtocol | dict[str, NoiseProtocol] | None = None, + ): + """Initializes a ControlledParametric. + + Arguments: + operation: Rotation gate. + control: Control qubit(s). + qubit_targets: Target qubit(s). + param_name: Name of parameters. + """ + support = Support(target, control) + super().__init__(operation, support, param_name, noise=noise) + + def extra_repr(self) -> str: + """String representation of the operation. + + Returns: + String with information on operation. + """ + return ( + f"control: {self.control}, target: {(self.target,)}, param: {self.param_name}" + + _repr_noise(self.noise) + ) + + def _construct_parametric_base_op( + self, values: dict[str, Tensor] = dict(), embedding: Embedding | None = None + ) -> Tensor: + """ + Get the corresponding unitary with parsed values and kronned identities + for control. + + Arguments: + values: A dict containing a Parameter name and value. + embedding: An optional embedding for parameters. + + Returns: + The unitary representation. + """ + thetas = self.parse_values(values, embedding) + batch_size = len(thetas) + mat = parametric_unitary(thetas, self.operation, self.identity, batch_size) + mat = controlled(mat, batch_size, len(self.control)) + return mat + + def jacobian( + self, values: dict[str, Tensor] = dict(), embedding: Embedding | None = None + ) -> Tensor: + """ + Get the corresponding unitary of the jacobian. + + Arguments: + values: Parameter value. + + Returns: + The unitary representation of the jacobian. + """ + thetas = self.parse_values(values, embedding) + batch_size = len(thetas) + n_control = len(self.control) + jU = _jacobian(thetas, self.operation, self.identity, batch_size) + n_dim = 2 ** (n_control + 1) + jC = ( + torch.zeros((n_dim, n_dim), dtype=self.identity.dtype) + .unsqueeze(2) + .repeat(1, 1, batch_size) + ) + unitary_idx = 2 ** (n_control + 1) - 2 + jC[unitary_idx:, unitary_idx:, :] = jU + return jC + + +class ControlledRotationGate(ControlledParametric): + """ + Primitives for controlled rotation operations. + """ + + n_params = 1 + + def __init__( + self, + operation: str | Tensor, + control: int | Tuple[int, ...], + target: int, + param_name: str | int | float | torch.Tensor = "", + noise: NoiseProtocol | dict[str, NoiseProtocol] | None = None, + ): + """Initializes a ControlledRotationGate. + + Arguments: + gate: Rotation gate. + control: Control qubit(s). + qubit_support: Target qubit. + param_name: Name of parameters. + """ + super().__init__(operation, control, target, param_name, noise=noise) + + @cached_property + def eigenvals_generator(self) -> Tensor: + """Get eigenvalues of the underlying generator. + + Arguments: + values: Parameter values. + + Returns: + Eigenvalues of the generator operator. + """ + return torch.cat( + ( + torch.zeros( + 2 ** len(self.qubit_support) - 2, + device=self.device, + dtype=self.dtype, + ), + pauli_singleq_eigenvalues.flatten().to( + device=self.device, dtype=self.dtype + ), + ) + ).reshape(-1, 1) diff --git a/pyqtorch/parametric.py b/pyqtorch/primitives/parametric_gates.py similarity index 63% rename from pyqtorch/parametric.py rename to pyqtorch/primitives/parametric_gates.py index d502d8d0..6d9b0ee3 100644 --- a/pyqtorch/parametric.py +++ b/pyqtorch/primitives/parametric_gates.py @@ -1,7 +1,7 @@ from __future__ import annotations from functools import cached_property -from typing import Any, Tuple +from typing import Any import torch from torch import Tensor @@ -9,330 +9,13 @@ from pyqtorch.embed import Embedding from pyqtorch.matrices import ( DEFAULT_MATRIX_DTYPE, - OPERATIONS_DICT, - _jacobian, controlled, - parametric_unitary, ) -from pyqtorch.noise import NoiseProtocol, _repr_noise -from pyqtorch.quantum_ops import QuantumOperation, Support -from pyqtorch.utils import Operator +from pyqtorch.noise import NoiseProtocol -pauli_singleq_eigenvalues = torch.tensor([[-1.0], [1.0]], dtype=torch.cdouble) - - -class Parametric(QuantumOperation): - """ - QuantumOperation taking parameters as input. - - Attributes: - param_name: Name of parameters. - parse_values: Method defining how to handle the values dictionary input. - """ - - n_params = 1 - - def __init__( - self, - generator: str | Tensor, - qubit_support: int | tuple[int, ...] | Support, - param_name: str | int | float | torch.Tensor = "", - noise: NoiseProtocol | dict[str, NoiseProtocol] | None = None, - ): - """Initializes Parametric. - - Arguments: - generator: Generator to use. - qubit_support: Qubits to act on. - param_name: Name of parameters. - noise: Optional noise protocols to apply. - """ - - generator_operation = ( - OPERATIONS_DICT[generator] if isinstance(generator, str) else generator - ) - self.param_name = param_name - - def parse_values( - values: dict[str, Tensor] | Tensor = dict(), - embedding: Embedding | None = None, - ) -> Tensor: - """The legacy way of using parametric gates: - The Parametric gate received a string as a 'param_name' and performs a - a lookup in the passed `values` dict for to retrieve the torch.Tensor passed - under the key `param_name`. - - Arguments: - values: A dict containing param_name:torch.Tensor pairs - embedding: An optional embedding. - Returns: - A Torch Tensor denoting values for the `param_name`. - """ - return Parametric._expand_values(values[self.param_name]) # type: ignore[index] - - def parse_tensor( - values: dict[str, Tensor] | Tensor = dict(), - embedding: Embedding | None = None, - ) -> Tensor: - """Functional version of the Parametric gate: - In case the user did not pass a `param_name`, - pyqtorch assumes `values` will be a torch.Tensor instead of a dict. - - Arguments: - values: A dict containing param_name:torch.Tensor pairs - Returns: - A Torch Tensor with which to evaluate the Parametric Gate. - """ - # self.param_name will be "" - return Parametric._expand_values(values) - - def parse_constant( - values: dict[str, Tensor] | Tensor = dict(), - embedding: Embedding | None = None, - ) -> Tensor: - """Fix a the parameter of a Parametric Gate to a numeric constant - if the user passed a numeric input for the `param_name`. - - Arguments: - values: A dict containing param_name:torch.Tensor pairs - Returns: - A Torch Tensor with which to evaluate the Parametric Gate. - """ - # self.param_name will be a torch.Tensor - return Parametric._expand_values( - torch.tensor(self.param_name, device=self.device, dtype=self.dtype) - ) - - if param_name == "": - self.parse_values = parse_tensor - self.param_name = self.param_name - elif isinstance(param_name, str): - self.parse_values = parse_values - elif isinstance(param_name, (float, int, torch.Tensor)): - self.parse_values = parse_constant - - # Parametric is defined by generator operation and a function - # The function will use parsed parameter values to compute the unitary - super().__init__( - generator_operation, - qubit_support, - operator_function=self._construct_parametric_base_op, - noise=noise, - ) - self.register_buffer("identity", OPERATIONS_DICT["I"]) - - def extra_repr(self) -> str: - """String representation of the operation. - - Returns: - String with information on operation. - """ - return f"target: {self.qubit_support}, param: {self.param_name}" + _repr_noise( - self.noise - ) - - def __hash__(self) -> int: - """Hash qubit support and param_name - - Returns: - Hash value - """ - return hash(self.qubit_support) + hash(self.param_name) - - @staticmethod - def _expand_values(values: Tensor) -> Tensor: - """Expand values if necessary. - - Arguments: - values: Values of parameters - Returns: - Values of parameters expanded. - - """ - return values.unsqueeze(0) if len(values.size()) == 0 else values - - def _construct_parametric_base_op( - self, - values: dict[str, Tensor] | Tensor = dict(), - embedding: Embedding | None = None, - ) -> Tensor: - """ - Get the corresponding unitary with parsed values. - - Arguments: - values: A dict containing a Parameter name and value. - embedding: An optional embedding for parameters. - - Returns: - The unitary representation. - """ - thetas = self.parse_values(values, embedding) - batch_size = len(thetas) - mat = parametric_unitary(thetas, self.operation, self.identity, batch_size) - return mat - - def jacobian( - self, - values: dict[str, Tensor] | Tensor = dict(), - embedding: Embedding | None = None, - ) -> Tensor: - """ - Get the corresponding unitary of the jacobian. - - Arguments: - values: Parameter value. - - Returns: - The unitary representation of the jacobian. - """ - thetas = self.parse_values(values, embedding) - batch_size = len(thetas) - return _jacobian(thetas, self.operation, self.identity, batch_size) - - def to(self, *args: Any, **kwargs: Any) -> Parametric: - super().to(*args, **kwargs) - self._device = self.operation.device - self.param_name = ( - self.param_name.to(*args, **kwargs) - if isinstance(self.param_name, torch.Tensor) - else self.param_name - ) - self._dtype = self.operation.dtype - return self - - -class ControlledParametric(Parametric): - """ - Primitives for controlled parametric operations. - - Attributes: - control: Control qubit(s). - """ - - def __init__( - self, - operation: str | Tensor, - control: int | Tuple[int, ...], - target: int | Tuple[int, ...], - param_name: str | int | float | torch.Tensor = "", - noise: NoiseProtocol | dict[str, NoiseProtocol] | None = None, - ): - """Initializes a ControlledParametric. +from .parametric import ControlledRotationGate, Parametric - Arguments: - operation: Rotation gate. - control: Control qubit(s). - qubit_targets: Target qubit(s). - param_name: Name of parameters. - """ - support = Support(target, control) - super().__init__(operation, support, param_name, noise=noise) - - def extra_repr(self) -> str: - """String representation of the operation. - - Returns: - String with information on operation. - """ - return ( - f"control: {self.control}, target: {(self.target,)}, param: {self.param_name}" - + _repr_noise(self.noise) - ) - - def _construct_parametric_base_op( - self, values: dict[str, Tensor] = dict(), embedding: Embedding | None = None - ) -> Operator: - """ - Get the corresponding unitary with parsed values and kronned identities - for control. - - Arguments: - values: A dict containing a Parameter name and value. - embedding: An optional embedding for parameters. - - Returns: - The unitary representation. - """ - thetas = self.parse_values(values, embedding) - batch_size = len(thetas) - mat = parametric_unitary(thetas, self.operation, self.identity, batch_size) - mat = controlled(mat, batch_size, len(self.control)) - return mat - - def jacobian( - self, values: dict[str, Tensor] = dict(), embedding: Embedding | None = None - ) -> Operator: - """ - Get the corresponding unitary of the jacobian. - - Arguments: - values: Parameter value. - - Returns: - The unitary representation of the jacobian. - """ - thetas = self.parse_values(values, embedding) - batch_size = len(thetas) - n_control = len(self.control) - jU = _jacobian(thetas, self.operation, self.identity, batch_size) - n_dim = 2 ** (n_control + 1) - jC = ( - torch.zeros((n_dim, n_dim), dtype=self.identity.dtype) - .unsqueeze(2) - .repeat(1, 1, batch_size) - ) - unitary_idx = 2 ** (n_control + 1) - 2 - jC[unitary_idx:, unitary_idx:, :] = jU - return jC - - -class ControlledRotationGate(ControlledParametric): - """ - Primitives for controlled rotation operations. - """ - - n_params = 1 - - def __init__( - self, - operation: str | Tensor, - control: int | Tuple[int, ...], - target: int, - param_name: str | int | float | torch.Tensor = "", - noise: NoiseProtocol | dict[str, NoiseProtocol] | None = None, - ): - """Initializes a ControlledRotationGate. - - Arguments: - gate: Rotation gate. - control: Control qubit(s). - qubit_support: Target qubit. - param_name: Name of parameters. - """ - super().__init__(operation, control, target, param_name, noise=noise) - - @cached_property - def eigenvals_generator(self) -> Tensor: - """Get eigenvalues of the underlying generator. - - Arguments: - values: Parameter values. - - Returns: - Eigenvalues of the generator operator. - """ - return torch.cat( - ( - torch.zeros( - 2 ** len(self.qubit_support) - 2, - device=self.device, - dtype=self.dtype, - ), - pauli_singleq_eigenvalues.flatten().to( - device=self.device, dtype=self.dtype - ), - ) - ).reshape(-1, 1) +pauli_singleq_eigenvalues = torch.tensor([[-1.0], [1.0]], dtype=torch.cdouble) class RX(Parametric): diff --git a/pyqtorch/primitives/primitive.py b/pyqtorch/primitives/primitive.py new file mode 100644 index 00000000..e6ce3cbc --- /dev/null +++ b/pyqtorch/primitives/primitive.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +from functools import cached_property +from typing import Any + +import torch +from torch import Tensor + +from pyqtorch.matrices import OPERATIONS_DICT, controlled +from pyqtorch.noise import NoiseProtocol, _repr_noise +from pyqtorch.quantum_operation import QuantumOperation, Support + + +class Primitive(QuantumOperation): + """Primitive operators based on a fixed matrix U. + + + Attributes: + operation (Tensor): Matrix U. + qubit_support: List of qubits the QuantumOperation acts on. + generator (Tensor): A tensor G s.t. U = exp(-iG). + """ + + def __init__( + self, + operation: Tensor, + qubit_support: int | tuple[int, ...] | Support, + generator: Tensor | None = None, + noise: NoiseProtocol | dict[str, NoiseProtocol] | None = None, + ) -> None: + super().__init__(operation, qubit_support, noise=noise) + self.generator = generator + + def to(self, *args: Any, **kwargs: Any) -> Primitive: + """Do device or dtype conversions. + + Returns: + Primitive: Converted instance. + """ + super().to(*args, **kwargs) + if self.generator is not None: + self.generator.to(*args, **kwargs) + return self + + @cached_property + def eigenvals_generator(self) -> Tensor: + """Get eigenvalues of the underlying generator. + + Note that for a primitive, the generator is unclear + so we execute pass. + + Arguments: + values: Parameter values. + + Returns: + Eigenvalues of the generator operator. + """ + if self.generator is not None: + return torch.linalg.eigvalsh(self.generator).reshape(-1, 1) + pass + + +class ControlledPrimitive(Primitive): + """Primitive applied depending on control qubits. + + Attributes: + operation (Tensor): Unitary tensor U. + control (int | tuple[int, ...]): List of qubits acting as controls. + target (int | tuple[int, ...]): List of qubits operations acts on. + """ + + def __init__( + self, + operation: str | Tensor, + control: int | tuple[int, ...], + target: int | tuple[int, ...], + noise: NoiseProtocol | dict[str, NoiseProtocol] | None = None, + ): + support = Support(target, control) + if isinstance(operation, str): + operation = OPERATIONS_DICT[operation] + operation = controlled( + operation=operation.unsqueeze(2), + batch_size=1, + n_control_qubits=len(support.control), + ).squeeze(2) + super().__init__(operation, support, noise=noise) + + def extra_repr(self) -> str: + return f"control: {self.control}, target: {self.target}" + _repr_noise( + self.noise + ) diff --git a/pyqtorch/primitive.py b/pyqtorch/primitives/primitive_gates.py similarity index 64% rename from pyqtorch/primitive.py rename to pyqtorch/primitives/primitive_gates.py index 5e8bc7c9..1b0aa43b 100644 --- a/pyqtorch/primitive.py +++ b/pyqtorch/primitives/primitive_gates.py @@ -1,99 +1,14 @@ from __future__ import annotations -from functools import cached_property -from typing import Any - -import torch -from torch import Tensor - -from pyqtorch.matrices import OPERATIONS_DICT, controlled -from pyqtorch.noise import NoiseProtocol, _repr_noise -from pyqtorch.quantum_ops import QuantumOperation, Support +from pyqtorch.matrices import OPERATIONS_DICT +from pyqtorch.noise import NoiseProtocol +from pyqtorch.quantum_operation import Support from pyqtorch.utils import ( product_state, qubit_support_as_tuple, ) - -class Primitive(QuantumOperation): - """Primitive operators based on a fixed matrix U. - - - Attributes: - operation (Tensor): Matrix U. - qubit_support: List of qubits the QuantumOperation acts on. - generator (Tensor): A tensor G s.t. U = exp(-iG). - """ - - def __init__( - self, - operation: Tensor, - qubit_support: int | tuple[int, ...] | Support, - generator: Tensor | None = None, - noise: NoiseProtocol | dict[str, NoiseProtocol] | None = None, - ) -> None: - super().__init__(operation, qubit_support, noise=noise) - self.generator = generator - - def to(self, *args: Any, **kwargs: Any) -> Primitive: - """Do device or dtype conversions. - - Returns: - Primitive: Converted instance. - """ - super().to(*args, **kwargs) - if self.generator is not None: - self.generator.to(*args, **kwargs) - return self - - @cached_property - def eigenvals_generator(self) -> Tensor: - """Get eigenvalues of the underlying generator. - - Note that for a primitive, the generator is unclear - so we execute pass. - - Arguments: - values: Parameter values. - - Returns: - Eigenvalues of the generator operator. - """ - if self.generator is not None: - return torch.linalg.eigvalsh(self.generator).reshape(-1, 1) - pass - - -class ControlledPrimitive(Primitive): - """Primitive applied depending on control qubits. - - Attributes: - operation (Tensor): Unitary tensor U. - control (int | tuple[int, ...]): List of qubits acting as controls. - target (int | tuple[int, ...]): List of qubits operations acts on. - """ - - def __init__( - self, - operation: str | Tensor, - control: int | tuple[int, ...], - target: int | tuple[int, ...], - noise: NoiseProtocol | dict[str, NoiseProtocol] | None = None, - ): - support = Support(target, control) - if isinstance(operation, str): - operation = OPERATIONS_DICT[operation] - operation = controlled( - operation=operation.unsqueeze(2), - batch_size=1, - n_control_qubits=len(support.control), - ).squeeze(2) - super().__init__(operation, support, noise=noise) - - def extra_repr(self) -> str: - return f"control: {self.control}, target: {self.target}" + _repr_noise( - self.noise - ) +from .primitive import ControlledPrimitive, Primitive class X(Primitive): diff --git a/pyqtorch/quantum_ops.py b/pyqtorch/quantum_operation.py similarity index 100% rename from pyqtorch/quantum_ops.py rename to pyqtorch/quantum_operation.py diff --git a/pyqtorch/utils.py b/pyqtorch/utils.py index badf81fa..dc7b18cd 100644 --- a/pyqtorch/utils.py +++ b/pyqtorch/utils.py @@ -394,7 +394,7 @@ def expand_operator( def promote_operator(operator: Tensor, target: int, n_qubits: int) -> Tensor: - from pyqtorch.primitive import I + from pyqtorch.primitives import I """ FIXME: Remove and replace usage with the `expand_operator` above. diff --git a/tests/conftest.py b/tests/conftest.py index 4b08e090..fcd0b7e3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -20,25 +20,23 @@ PhaseDamping, PhaseFlip, ) -from pyqtorch.parametric import ( +from pyqtorch.primitives import ( + CNOT, CPHASE, CRX, CRY, CRZ, + CY, + CZ, PHASE, RX, RY, RZ, - ControlledRotationGate, - Parametric, -) -from pyqtorch.primitive import ( - CNOT, - CY, - CZ, ControlledPrimitive, + ControlledRotationGate, H, I, + Parametric, Primitive, S, T, diff --git a/tests/helpers.py b/tests/helpers.py index 5900ca08..3a1df9f2 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -4,19 +4,16 @@ import torch -from pyqtorch.analog import Add, Scale from pyqtorch.apply import apply_operator -from pyqtorch.circuit import Sequence -from pyqtorch.parametric import ( - OPS_PARAM_1Q, - OPS_PARAM_2Q, - Parametric, -) -from pyqtorch.primitive import ( +from pyqtorch.composite import Add, Scale, Sequence +from pyqtorch.primitives import ( OPS_1Q, OPS_2Q, OPS_3Q, + OPS_PARAM_1Q, + OPS_PARAM_2Q, OPS_PAULI, + Parametric, Primitive, Toffoli, ) diff --git a/tests/test_analog.py b/tests/test_analog.py index 4aed5d44..4d702574 100644 --- a/tests/test_analog.py +++ b/tests/test_analog.py @@ -8,7 +8,7 @@ from helpers import calc_mat_vec_wavefunction, random_pauli_hamiltonian import pyqtorch as pyq -from pyqtorch.analog import GeneratorType +from pyqtorch.hamiltonians import GeneratorType from pyqtorch.matrices import ( DEFAULT_MATRIX_DTYPE, DEFAULT_REAL_DTYPE, diff --git a/tests/test_circuit.py b/tests/test_circuit.py index 592b420e..26095677 100644 --- a/tests/test_circuit.py +++ b/tests/test_circuit.py @@ -20,7 +20,7 @@ def test_device_inference() -> None: @pytest.mark.parametrize("fn", [pyq.X, pyq.Z, pyq.Y]) -def test_scale(fn: pyq.primitive.Primitive) -> None: +def test_scale(fn: pyq.primitives.Primitive) -> None: n_qubits = torch.randint(low=1, high=4, size=(1,)).item() target = random.choice([i for i in range(n_qubits)]) state = pyq.random_state(n_qubits) diff --git a/tests/test_differentiation.py b/tests/test_differentiation.py index 8c415040..2be244ad 100644 --- a/tests/test_differentiation.py +++ b/tests/test_differentiation.py @@ -6,7 +6,7 @@ import pyqtorch as pyq from pyqtorch import DiffMode, expectation from pyqtorch.matrices import COMPLEX_TO_REAL_DTYPES -from pyqtorch.primitive import Primitive +from pyqtorch.primitives import Primitive from pyqtorch.utils import ( GRADCHECK_ATOL, ) diff --git a/tests/test_digital.py b/tests/test_digital.py index b65f813f..0605172b 100644 --- a/tests/test_digital.py +++ b/tests/test_digital.py @@ -13,8 +13,7 @@ from pyqtorch.matrices import ( DEFAULT_MATRIX_DTYPE, ) -from pyqtorch.parametric import Parametric -from pyqtorch.primitive import Primitive +from pyqtorch.primitives import Parametric, Primitive from pyqtorch.utils import ( ATOL, product_state, @@ -331,7 +330,7 @@ def test_U() -> None: @pytest.mark.parametrize("gate", [pyq.RX, pyq.RY, pyq.RZ]) -def test_parametric_constantparam(gate: pyq.parametric.Parametric) -> None: +def test_parametric_constantparam(gate: Parametric) -> None: n_qubits = 4 max_batch_size = 10 target = torch.randint(0, n_qubits, (1,)).item() diff --git a/tests/test_embedding.py b/tests/test_embedding.py index 342dec62..f4015f0c 100644 --- a/tests/test_embedding.py +++ b/tests/test_embedding.py @@ -11,7 +11,7 @@ import pyqtorch as pyq from pyqtorch.embed import ConcretizedCallable, Embedding -from pyqtorch.primitive import Primitive +from pyqtorch.primitives import Primitive from pyqtorch.utils import ATOL_embedding diff --git a/tests/test_gpsr.py b/tests/test_gpsr.py index 116b05d7..e6d14e73 100644 --- a/tests/test_gpsr.py +++ b/tests/test_gpsr.py @@ -8,10 +8,10 @@ import pyqtorch as pyq from pyqtorch import DiffMode, expectation -from pyqtorch.analog import Observable from pyqtorch.circuit import QuantumCircuit +from pyqtorch.hamiltonians import Observable from pyqtorch.matrices import COMPLEX_TO_REAL_DTYPES -from pyqtorch.parametric import Parametric +from pyqtorch.primitives import Parametric from pyqtorch.utils import GPSR_ACCEPTANCE, PSR_ACCEPTANCE, GRADCHECK_sampling_ATOL diff --git a/tests/test_noise.py b/tests/test_noise.py index 16d0dcc4..67c4b1b8 100644 --- a/tests/test_noise.py +++ b/tests/test_noise.py @@ -22,8 +22,17 @@ Noise, PhaseDamping, ) -from pyqtorch.parametric import ControlledRotationGate, Parametric -from pyqtorch.primitive import ControlledPrimitive, H, I, Primitive, X, Y, Z +from pyqtorch.primitives import ( + ControlledPrimitive, + ControlledRotationGate, + H, + I, + Parametric, + Primitive, + X, + Y, + Z, +) from pyqtorch.utils import ( DensityMatrix, density_mat, diff --git a/tests/test_tensor.py b/tests/test_tensor.py index 74643cc7..503920d2 100644 --- a/tests/test_tensor.py +++ b/tests/test_tensor.py @@ -6,14 +6,15 @@ import torch from helpers import calc_mat_vec_wavefunction, get_op_support, random_pauli_hamiltonian -from pyqtorch.analog import Add, GeneratorType, HamiltonianEvolution, Scale -from pyqtorch.circuit import Sequence -from pyqtorch.parametric import OPS_PARAM, Parametric -from pyqtorch.primitive import ( +from pyqtorch.composite import Add, Scale, Sequence +from pyqtorch.hamiltonians import GeneratorType, HamiltonianEvolution +from pyqtorch.primitives import ( CNOT, OPS_DIGITAL, + OPS_PARAM, SWAP, N, + Parametric, Primitive, Projector, )