From 7d1134d0a2b2d022f1272e2a7901495b959f9b39 Mon Sep 17 00:00:00 2001 From: grossardt <99898527+grossardt@users.noreply.github.com> Date: Sun, 12 Nov 2023 00:29:26 +0100 Subject: [PATCH 01/22] Add MajoranaOp class --- .pylintdict | 4 + qiskit_nature/second_q/operators/__init__.py | 3 + .../second_q/operators/fermionic_op.py | 5 +- .../second_q/operators/majorana_op.py | 508 +++++++++++++ .../add-majoranaop-1cbf9d4a1d4c264e.yaml | 25 + test/second_q/operators/test_majorana_op.py | 693 ++++++++++++++++++ 6 files changed, 1236 insertions(+), 2 deletions(-) create mode 100644 qiskit_nature/second_q/operators/majorana_op.py create mode 100644 releasenotes/notes/add-majoranaop-1cbf9d4a1d4c264e.yaml create mode 100644 test/second_q/operators/test_majorana_op.py diff --git a/.pylintdict b/.pylintdict index d5213ea68..5d74edd8c 100644 --- a/.pylintdict +++ b/.pylintdict @@ -78,6 +78,7 @@ chkfile cholesky chuang ci +classmethod clbit clbits clifford @@ -300,6 +301,7 @@ kwargs kwds labelled langle +lbl lbrace lda ldots @@ -325,6 +327,7 @@ lvert lysine macos majorana +majoranaop makefile matmul matplotlib @@ -450,6 +453,7 @@ pxd py pydata pyquante +pyright pyscf qarg qargs diff --git a/qiskit_nature/second_q/operators/__init__.py b/qiskit_nature/second_q/operators/__init__.py index 5ac9b844e..95e6bb445 100644 --- a/qiskit_nature/second_q/operators/__init__.py +++ b/qiskit_nature/second_q/operators/__init__.py @@ -23,6 +23,7 @@ ElectronicIntegrals FermionicOp + MajoranaOp BosonicOp SparseLabelOp SpinOp @@ -44,6 +45,7 @@ from .electronic_integrals import ElectronicIntegrals from .fermionic_op import FermionicOp +from .majorana_op import MajoranaOp from .bosonic_op import BosonicOp from .spin_op import SpinOp from .vibrational_op import VibrationalOp @@ -55,6 +57,7 @@ __all__ = [ "ElectronicIntegrals", "FermionicOp", + "MajoranaOp", "BosonicOp", "SpinOp", "VibrationalOp", diff --git a/qiskit_nature/second_q/operators/fermionic_op.py b/qiskit_nature/second_q/operators/fermionic_op.py index d2e508a5a..8211f4024 100644 --- a/qiskit_nature/second_q/operators/fermionic_op.py +++ b/qiskit_nature/second_q/operators/fermionic_op.py @@ -143,7 +143,6 @@ class FermionicOp(SparseLabelOp): However, a FermionicOp containing parameters does not support the following methods: - ``is_hermitian`` - - ``to_matrix`` """ _OPERATION_REGEX = re.compile(r"([\+\-]_\d+\s)*[\+\-]_\d+") @@ -460,7 +459,8 @@ def index_order(self) -> FermionicOp: } ) - def _index_order(self, terms: list[tuple[str, int]], coeff: _TCoeff) -> tuple[str, _TCoeff]: + @classmethod + def _index_order(cls, terms: list[tuple[str, int]], coeff: _TCoeff) -> tuple[str, _TCoeff]: if not terms: return "", coeff @@ -501,6 +501,7 @@ def simplify(self, atol: float | None = None) -> FermionicOp: data = defaultdict(complex) # type: dict[str, _TCoeff] # TODO: use parallel_map to make this more efficient (?) + # (if this is done, apply equally to MajoranaOp.simplify()) for label, coeff in self.items(): label, coeff = self._simplify_label(label, coeff) data[label] += coeff diff --git a/qiskit_nature/second_q/operators/majorana_op.py b/qiskit_nature/second_q/operators/majorana_op.py new file mode 100644 index 000000000..4dd035554 --- /dev/null +++ b/qiskit_nature/second_q/operators/majorana_op.py @@ -0,0 +1,508 @@ +# This code is part of a Qiskit project. +# +# (C) Copyright IBM 2023. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +"""The Majorana-particle Operator.""" + + +from __future__ import annotations + +import re +from collections import defaultdict +from collections.abc import Collection, Mapping +from typing import Iterator, Sequence + +import numpy as np + +from qiskit_nature.exceptions import QiskitNatureError + +from .polynomial_tensor import PolynomialTensor +from .sparse_label_op import _TCoeff, SparseLabelOp, _to_number +from .fermionic_op import FermionicOp + + +class MajoranaOp(SparseLabelOp): + r"""N-mode Majorana operator. + + A ``MajoranaOp`` represents a weighted sum of Majorana fermion operator terms. + These terms are encoded as sparse labels, which are strings consisting of a space-separated list + of expressions. Each expression must look like :code:`_`, where the :code:`` is a + non-negative integer representing the index of the mode on which the Majorana + creation/annihilation operator is applied. The value of :code:`index` is bound by twice the + number of spin orbitals (``num_spin_orbitals``) of the operator (Note: since Python indices are + 0-based, the maximum value an index can take is given by :code:`2 * num_spin_orbitals - 1`). + + **Initialization** + + A ``MajoranaOp`` is initialized with a dictionary, mapping terms to their respective + coefficients: + + .. code-block:: python + + from qiskit_nature.second_q.operators import MajoranaOp + + op = MajoranaOp( + { + "_0 _1": .25j, + "_1 _0": -.25j, + "_2 _3": -.25j, + "_3 _2": .25j, + }, + num_spin_orbitals=2, + ) + + By default, this way of initializing will create a full copy of the dictionary of coefficients. + If you have very restricted memory resources available, or would like to avoid the additional + copy, the dictionary will be stored by reference if you disable ``copy`` like so: + + .. code-block:: python + + some_big_data = { + "_0 _1": .25j, + "_1 _0": -.25j, + # ... + } + + op = MajoranaOp( + some_big_data, + num_spin_orbitals=2, + copy=False, + ) + + + .. note:: + + It is the users' responsibility, that in the above scenario, :code:`some_big_data` is not + changed after initialization of the ``MajoranaOp``, since the operator contents are not + guaranteed to remain unaffected by such changes. + + **Construction from Fermionic operator** + + The default way to construct a ``MajoranaOp`` is from an existing ``FermionicOp``: + + .. code-block:: python + + from qiskit_nature.second_q.operators import FermionicOp, MajoranaOp + f_op = FermionicOp({"+_0 -_1": 1}, num_spin_orbitals=2) + m_op = MajoranaOp.from_fermionic_op(f_op) + + Note that every term of the ``FermionicOp`` will result in :math:`2^n` terms in the + ``MajoranaOp``, where :math:`n` is the number of fermionic modes in the term. The conversion + uses the convention that + + .. math:: + + a_i = \frac{1}{2}(\gamma_{2i} + i \gamma_{2i+1}), \quad + a_i^\dagger = \frac{1}{2}(\gamma_{2i} - i \gamma_{2i+1}) \,, + + where :math:`a_i` and :math:`a_i^\dagger` are the Fermionic annihilation and creation operators + and :math:`\gamma_i` the Majorana operators. + + .. note:: + + When creating a ``MajoranaOp`` from a ``PolynomialTensor`` using + :meth:`from_polynomial_tensor`, the underscore character :code:`_` is the only allowed + character in the keys of the ``PolynomialTensor``. + + **Algebra** + + This class supports the following basic arithmetic operations: addition, subtraction, scalar + multiplication, operator multiplication, and adjoint. + For example, + + Addition + + .. code-block:: python + + MajoranaOp({"_1": 1}, num_spin_orbitals=2) + MajoranaOp({"_0": 1}, num_spin_orbitals=2) + + Sum + + .. code-block:: python + + sum(MajoranaOp({label: 1}, num_spin_orbitals=3) for label in ["_0", "_1", "_2 _3"]) + + Scalar multiplication + + .. code-block:: python + + 0.5 * MajoranaOp({"_1": 1}, num_spin_orbitals=2) + + Operator multiplication + + .. code-block:: python + + op1 = MajoranaOp({"_0 _1": 1}, num_spin_orbitals=2) + op2 = MajoranaOp({"_0 _1 _2": 1}, num_spin_orbitals=2) + print(op1 @ op2) + + Tensor multiplication + + .. code-block:: python + + op = MajoranaOp({"_0 _1": 1}, num_spin_orbitals=2) + print(op ^ op) + + Adjoint + + .. code-block:: python + + MajoranaOp({"_0 _1": 1j}, num_spin_orbitals=2).adjoint() + + .. note:: + + Since Majorana generators are self-adjoined, the adjoint of a ``MajoranaOp`` is the original + operator with all strings reversed, e.g. :code:`"_0 _1"` becomes :code:`"_1 _0"` in the + example above, and coefficients complex conjugated. + + **Iteration** + + Instances of ``MajoranaOp`` are iterable. Iterating a ``MajoranaOp`` yields (term, coefficient) + pairs describing the terms contained in the operator. + + Attributes: + num_spin_orbitals (int | None): the number of spin orbitals on which this operator acts. + This is considered a lower bound, which means that mathematical operations acting on two + or more operators will result in a new operator with the maximum number of spin orbitals + of any of the involved operators. + + .. note:: + + ``MajoranaOp`` can contain :class:`qiskit.circuit.ParameterExpression` objects as + coefficients. However, a ``MajoranaOp`` containing parameters does not support the following + methods: + + - ``is_hermitian`` + """ + + _OPERATION_REGEX = re.compile(r"(_\d+\s)*_\d+") + + def __init__( + self, + data: Mapping[str, _TCoeff], + num_spin_orbitals: int | None = None, + *, + copy: bool = True, + validate: bool = True, + ) -> None: + """ + Args: + data: the operator data, mapping string-based keys to numerical values. + num_spin_orbitals: the number of spin orbitals on which this operator acts. + copy: when set to False the ``data`` will not be copied and the dictionary will be + stored by reference rather than by value (which is the default; ``copy=True``). + Note, that this requires you to not change the contents of the dictionary after + constructing the operator. This also implies ``validate=False``. Use with care! + validate: when set to False the ``data`` keys will not be validated. Note, that the + SparseLabelOp base class, makes no assumption about the data keys, so will not + perform any validation by itself. Only concrete subclasses are encouraged to + implement a key validation method. Disable this setting with care! + + Raises: + QiskitNatureError: when an invalid key is encountered during validation. + """ + self.num_spin_orbitals = num_spin_orbitals + # if num_spin_orbitals is None, it is set during _validate_keys + super().__init__(data, copy=copy, validate=validate) + + @property + def register_length(self) -> int: + if self.num_spin_orbitals is None: + max_index = max(int(term[1:]) for key in self._data for term in key.split()) + if max_index % 2 == 0: + max_index += 1 + return max_index + 1 + + return 2 * self.num_spin_orbitals + + def _new_instance( + self, data: Mapping[str, _TCoeff], *, other: MajoranaOp | None = None + ) -> MajoranaOp: + num_so = self.num_spin_orbitals + if other is not None: + other_num_so = other.num_spin_orbitals + if num_so is None: + num_so = other_num_so + elif other_num_so is not None: + num_so = max(num_so, other_num_so) + + return self.__class__(data, copy=False, num_spin_orbitals=num_so) + + def _validate_keys(self, keys: Collection[str]) -> None: + super()._validate_keys(keys) + + num_so = self.num_spin_orbitals + + max_index = -1 + + for key in keys: + # 0. explicitly allow the empty key + if key == "": + continue + + # 1. validate overall key structure + if not re.fullmatch(MajoranaOp._OPERATION_REGEX, key): + raise QiskitNatureError(f"{key} is not a valid MajoranaOp label.") + + # 2. validate all indices against register length + for term in key.split(): + index = int(term[1:]) + if num_so is None: + if index > max_index: + max_index = index + elif index >= 2 * num_so: + raise QiskitNatureError( + f"The index, {index}, from the label, {key}, exceeds twice the number of " + f"spin orbitals, {num_so}." + ) + + if num_so is None: + self.num_spin_orbitals = (max_index + 1 if max_index % 2 else max_index + 2) // 2 + + @classmethod + def _validate_polynomial_tensor_key(cls, keys: Collection[str]) -> None: + # PolynomialTensor keys cannot be built from empty string, + # hence we choose _ to be the only allowed character + allowed_chars = {"_"} + + for key in keys: + if set(key) - allowed_chars: + raise QiskitNatureError( + f"The key {key} is invalid. PolynomialTensor keys may only consists of `_` " + "characters, for them to be expandable into a MajoranaOp." + ) + + @classmethod + def from_polynomial_tensor(cls, tensor: PolynomialTensor) -> MajoranaOp: + cls._validate_polynomial_tensor_key(tensor.keys()) + + data: dict[str, _TCoeff] = {} + + for key in tensor: + if key == "": + data[""] = tensor[key].item() + continue + + mat = tensor[key] + + empty_string_key = ["" for _ in key] # label format for Majorana is just '_' + label_template = mat.label_template.format(*empty_string_key) + + for value, index in mat.coord_iter(): + data[label_template.format(*index)] = value + + num_so = (tensor.register_length + 1) // 2 + return cls(data, copy=False, num_spin_orbitals=num_so).chop() + + def __repr__(self) -> str: + data_str = f"{dict(self.items())}" + + return "MajoranaOp(" f"{data_str}, " f"num_spin_orbitals={self.num_spin_orbitals}, " ")" + + def __str__(self) -> str: + pre = ( + "Majorana Operator\n" + f"number spin orbitals={self.num_spin_orbitals}, number terms={len(self)}\n" + ) + ret = " " + "\n+ ".join( + [f"{coeff} * ( {label} )" if label else f"{coeff}" for label, coeff in self.items()] + ) + return pre + ret + + def terms(self) -> Iterator[tuple[list[tuple[str, int]], _TCoeff]]: + """Provides an iterator analogous to :meth:`items` but with the labels already split into + pairs of operation characters and indices. + + Yields: + A tuple with two items; the first one being a list of pairs of the form (char, int) + where char is always an empty string (for compatibility with other SparseLabelOps) and + the integer corresponds to the mode index on which the operator gets applied; the second + item of the returned tuple is the coefficient of this term. + """ + for label in iter(self): + if not label: + yield ([], self[label]) + continue + # label.split() will return lbl = '_' for each term + # lbl[1:] corresponds to the index + terms = [("", int(lbl[1:])) for lbl in label.split()] + yield (terms, self[label]) + + @classmethod + def from_terms(cls, terms: Sequence[tuple[list[tuple[str, int]], _TCoeff]]) -> MajoranaOp: + data = {" ".join(f"_{index}" for _, index in label): value for label, value in terms} + return cls(data) + + @classmethod + def from_fermionic_op( + cls, op: FermionicOp, simplify: bool = True, order: bool = True + ) -> MajoranaOp: + """Constructs the operator from a :class:`~.FermionicOp`. + + Args: + op: the :class:`~.FermionicOp` to convert. + simplify: whether to simplify the resulting operator. + order: whether to perform index ordering on the resulting operator. + + Returns: + The converted :class:`~.MajoranaOp`. + """ + data = defaultdict(complex) # type: dict[str, _TCoeff] + for label, coeff in op._data.items(): + terms = label.split() + for i in range(2 ** len(terms)): + majorana_label = "" + coeff_power = 0 + for j, term in enumerate(terms): + if majorana_label: + majorana_label += " " + odd_index = (i >> j) & 1 + index = 2 * int(term[2:]) + odd_index + if odd_index: + if term[0] == "-": + coeff_power += 1 + else: + coeff_power += 3 + majorana_label += f"_{index}" + new_coeff = 1j**coeff_power * coeff / 2 ** len(terms) + if order: + trms = next(trm for trm, _ in MajoranaOp({majorana_label: new_coeff}).terms()) + majorana_label, new_coeff = FermionicOp._index_order(trms, new_coeff) + if simplify: + majorana_label, new_coeff = cls._simplify_label(majorana_label, new_coeff) + data[majorana_label] += new_coeff + return cls(data, num_spin_orbitals=op.num_spin_orbitals) + + def _permute_term( + self, term: list[tuple[str, int]], permutation: Sequence[int] + ) -> list[tuple[str, int]]: + return [(action, permutation[index]) for action, index in term] + + def compose(self, other: MajoranaOp, qargs=None, front: bool = False) -> MajoranaOp: + if not isinstance(other, MajoranaOp): + raise TypeError( + f"Unsupported operand type(s) for *: 'MajoranaOp' and '{type(other).__name__}'" + ) + + if front: + return self._tensor(self, other, offset=False) + else: + return self._tensor(other, self, offset=False) + + def tensor(self, other: MajoranaOp) -> MajoranaOp: + return self._tensor(self, other) + + def expand(self, other: MajoranaOp) -> MajoranaOp: + return self._tensor(other, self) + + @classmethod + def _tensor(cls, a: MajoranaOp, b: MajoranaOp, *, offset: bool = True) -> MajoranaOp: + shift = 2 * a.num_spin_orbitals if offset else 0 + + new_data: dict[str, _TCoeff] = {} + for label1, cf1 in a.items(): + for terms2, cf2 in b.terms(): + new_label = f"{label1} {' '.join(f'_{i+shift}' for _, i in terms2)}".strip() + if new_label in new_data: + new_data[new_label] += cf1 * cf2 + else: + new_data[new_label] = cf1 * cf2 + + new_op = a._new_instance(new_data, other=b) + if offset: + new_op.num_spin_orbitals = a.num_spin_orbitals + b.num_spin_orbitals + return new_op + + def transpose(self) -> MajoranaOp: + data = {} + + for label, coeff in self.items(): + data[" ".join(lbl for lbl in reversed(label.split()))] = coeff + + return self._new_instance(data) + + def index_order(self) -> MajoranaOp: + """Convert to the equivalent operator with the terms of each label ordered by index. + + Returns a new operator (the original operator is not modified). + + .. note:: + + You can use this method to achieve the most aggressive simplification. + :meth:`simplify` does *not* reorder the terms and, thus, cannot deduce + ``_0 _1 _2`` and ``_2 _0 _1 _0 _0`` to be identical labels. + Calling this method will reorder the latter label to + ``_0 _0 _0 _1 _2``, after which :meth:`simplify` will be able to correctly + collapse these two labels into one. + + Returns: + The index ordered operator. + """ + data = defaultdict(complex) # type: dict[str, _TCoeff] + for terms, coeff in self.terms(): + # index ordering is identical to FermionicOp, hence we call classmethod there: + label, coeff = FermionicOp._index_order(terms, coeff) + data[label] += coeff + + # after successful index ordering, we remove all zero coefficients + return self._new_instance( + { + label: coeff + for label, coeff in data.items() + if not np.isclose(_to_number(coeff), 0.0, atol=self.atol) + } + ) + + def is_hermitian(self, atol: float | None = None) -> bool: + """Checks whether the operator is hermitian. + + Args: + atol: Absolute numerical tolerance. The default behavior is to use ``self.atol``. + + Returns: + True if the operator is hermitian up to numerical tolerance, False otherwise. + + Raises: + ValueError: Operator contains parameters. + """ + if self.is_parameterized(): + raise ValueError("is_hermitian is not supported for operators containing parameters.") + atol = self.atol if atol is None else atol + diff = (self - self.adjoint()).simplify(atol=atol) + return all(np.isclose(coeff, 0.0, atol=atol) for coeff in diff.values()) + + def simplify(self, atol: float | None = None) -> MajoranaOp: + atol = self.atol if atol is None else atol + + data = defaultdict(complex) # type: dict[str, _TCoeff] + for label, coeff in self.items(): + label, coeff = self._simplify_label(label, coeff) + data[label] += coeff + simplified_data = { + label: coeff + for label, coeff in data.items() + if not np.isclose(_to_number(coeff), 0.0, atol=atol) + } + return self._new_instance(simplified_data) + + @classmethod + def _simplify_label(cls, label: str, coeff: _TCoeff) -> tuple[str, _TCoeff]: + new_label_list = [] + for lbl in label.split()[::-1]: + index = int(lbl[1:]) + if index not in new_label_list: + new_label_list.append(index) + else: + if (len(new_label_list) - new_label_list.index(index)) % 2 == 0: + coeff *= -1 + new_label_list.remove(index) + new_label_list.reverse() + return " ".join(map(lambda index: f"_{index}", new_label_list)), coeff diff --git a/releasenotes/notes/add-majoranaop-1cbf9d4a1d4c264e.yaml b/releasenotes/notes/add-majoranaop-1cbf9d4a1d4c264e.yaml new file mode 100644 index 000000000..48615b609 --- /dev/null +++ b/releasenotes/notes/add-majoranaop-1cbf9d4a1d4c264e.yaml @@ -0,0 +1,25 @@ +--- +features: + - | + Adds a new operator class, :class:`~qiskit_nature.second_q.operators.MajoranaOp` + to handle operators that are sums of tensor products of Majorana fermion operators. + This is needed for the implementation of the Ternary Tree Mapper, but might be useful + later on for other purposes as well. + + Majorana operators use a string representation with underscore only, e.g. ``'_0 _1'`` + corresponds to :math:`\gamma_0 \gamma_1` where there are twice the number of spin orbitals + operators satisfying :math:`\{\gamma_i,\gamma_j\} = 2 \delta_{ij}`. + + Methods of :class:`~qiskit_nature.second_q.operators.MajoranaOp` are the same as for + :class:`~qiskit_nature.second_q.operators.FermionicOp` except for normal ordering, which is + unnecessary. A Majorana operator can be created from a Fermionic operator using the + :meth:`~qiskit_nature.second_q.operators.MajoranaOp.from_fermionic_op` class method. +other: + - | + The hidden method :meth:`~qiskit_nature.second_q.operators.FermionicOp._index_order` + has been converted to a class method to be able to be called within + :meth:`~qiskit_nature.second_q.operators.MajoranaOp.index_order`. + - | + Reference to `to_matrix` method in the documentation of + :class:`~qiskit_nature.second_q.operators.FermionicOp` has been removed as there is no such + method. diff --git a/test/second_q/operators/test_majorana_op.py b/test/second_q/operators/test_majorana_op.py new file mode 100644 index 000000000..3413a505b --- /dev/null +++ b/test/second_q/operators/test_majorana_op.py @@ -0,0 +1,693 @@ +# This code is part of a Qiskit project. +# +# (C) Copyright IBM 2023. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +"""Test for MajoranaOp""" + +import unittest + +from test import QiskitNatureTestCase + +import numpy as np +from ddt import data, ddt, unpack +from qiskit.circuit import Parameter + +from qiskit_nature.exceptions import QiskitNatureError +from qiskit_nature.second_q.operators import MajoranaOp, FermionicOp, PolynomialTensor +import qiskit_nature.optionals as _optionals + + +@ddt +class TestMajoranaOp(QiskitNatureTestCase): + """MajoranaOp tests.""" + + a = Parameter("a") + b = Parameter("b") + + op1 = MajoranaOp({"_0 _1": 1}) + op2 = MajoranaOp({"_1 _0": 2}) + op3 = MajoranaOp({"_0 _1": 1, "_1 _0": 2}) + op4 = MajoranaOp({"_0 _1": a}) + + def test_neg(self): + """Test __neg__""" + maj_op = -self.op1 + targ = MajoranaOp({"_0 _1": -1}, num_spin_orbitals=1) + self.assertEqual(maj_op, targ) + + maj_op = -self.op4 + targ = MajoranaOp({"_0 _1": -self.a}) + self.assertEqual(maj_op, targ) + + def test_mul(self): + """Test __mul__, and __rmul__""" + with self.subTest("rightmul"): + maj_op = self.op1 * 2 + targ = MajoranaOp({"_0 _1": 2}, num_spin_orbitals=1) + self.assertEqual(maj_op, targ) + + maj_op = self.op1 * self.a + targ = MajoranaOp({"_0 _1": self.a}) + self.assertEqual(maj_op, targ) + + with self.subTest("left mul"): + maj_op = (2 + 1j) * self.op3 + targ = MajoranaOp({"_0 _1": (2 + 1j), "_1 _0": (4 + 2j)}, num_spin_orbitals=1) + self.assertEqual(maj_op, targ) + + def test_div(self): + """Test __truediv__""" + maj_op = self.op1 / 2 + targ = MajoranaOp({"_0 _1": 0.5}, num_spin_orbitals=1) + self.assertEqual(maj_op, targ) + + maj_op = self.op1 / self.a + targ = MajoranaOp({"_0 _1": 1 / self.a}) + self.assertEqual(maj_op, targ) + + def test_add(self): + """Test __add__""" + maj_op = self.op1 + self.op2 + targ = self.op3 + self.assertEqual(maj_op, targ) + + maj_op = self.op1 + self.op4 + targ = MajoranaOp({"_0 _1": 1 + self.a}) + self.assertEqual(maj_op, targ) + + with self.subTest("sum"): + maj_op = sum(MajoranaOp({label: 1}) for label in ["_0", "_1", "_2 _3"]) + targ = MajoranaOp({"_0": 1, "_1": 1, "_2 _3": 1}) + self.assertEqual(maj_op, targ) + + def test_sub(self): + """Test __sub__""" + maj_op = self.op3 - self.op2 + targ = MajoranaOp({"_0 _1": 1, "_1 _0": 0}, num_spin_orbitals=1) + self.assertEqual(maj_op, targ) + + maj_op = self.op4 - self.op1 + targ = MajoranaOp({"_0 _1": self.a - 1}) + self.assertEqual(maj_op, targ) + + def test_compose(self): + """Test operator composition""" + with self.subTest("single compose"): + maj_op = MajoranaOp({"_0 _2": 1}, num_spin_orbitals=2) @ MajoranaOp( + {"_1": 1}, num_spin_orbitals=2 + ) + targ = MajoranaOp({"_0 _2 _1": 1}, num_spin_orbitals=2) + self.assertEqual(maj_op, targ) + + with self.subTest("single compose with parameters"): + maj_op = MajoranaOp({"_0 _2": self.a}) @ MajoranaOp({"_1": 1}) + targ = MajoranaOp({"_0 _2 _1": self.a}) + self.assertEqual(maj_op, targ) + + with self.subTest("multi compose"): + maj_op = MajoranaOp({"_0 _2 _3": 1, "_1 _2 _3": 1}, num_spin_orbitals=2) @ MajoranaOp( + {"": 1, "_1 _3": 1}, num_spin_orbitals=2 + ) + maj_op = maj_op.simplify() + targ = MajoranaOp( + {"_0 _2 _3": 1, "_1 _2 _3": 1, "_0 _2 _1": -1, "_2": 1}, + num_spin_orbitals=2, + ) + self.assertEqual(maj_op, targ) + + with self.subTest("multi compose with parameters"): + maj_op = MajoranaOp({"_0 _2 _3": self.a, "_1 _0 _3": 1}) @ MajoranaOp( + {"": 1, "_0 _3": self.b} + ) + maj_op = maj_op.simplify() + targ = MajoranaOp( + { + "_0 _2 _3": self.a, + "_1 _0 _3": 1, + "_2": self.a * self.b, + "_1": -self.b, + } + ) + self.assertEqual(maj_op, targ) + + def test_tensor(self): + """Test tensor multiplication""" + maj_op = self.op1.tensor(self.op2) + targ = MajoranaOp({"_0 _1 _3 _2": 2}, num_spin_orbitals=2) + self.assertEqual(maj_op, targ) + + maj_op = self.op4.tensor(self.op2) + targ = MajoranaOp({"_0 _1 _3 _2": 2 * self.a}) + self.assertEqual(maj_op, targ) + + def test_expand(self): + """Test reversed tensor multiplication""" + maj_op = self.op1.expand(self.op2) + targ = MajoranaOp({"_1 _0 _2 _3": 2}, num_spin_orbitals=2) + self.assertEqual(maj_op, targ) + + maj_op = self.op4.expand(self.op2) + targ = MajoranaOp({"_1 _0 _2 _3": 2 * self.a}) + self.assertEqual(maj_op, targ) + + def test_pow(self): + """Test __pow__""" + with self.subTest("square"): + maj_op = MajoranaOp({"_0 _1 _2": 3, "_1 _0 _3": 1}, num_spin_orbitals=2) ** 2 + maj_op = maj_op.simplify() + targ = MajoranaOp({"": -10, "_2 _3": 3, "_3 _2": 3}, num_spin_orbitals=2) + self.assertEqual(maj_op, targ) + + with self.subTest("3rd power"): + maj_op = (3 * MajoranaOp.one()) ** 3 + targ = 27 * MajoranaOp.one() + self.assertEqual(maj_op, targ) + + with self.subTest("0th power"): + maj_op = MajoranaOp({"_0 _1 _2": 3, "_1 _0 _3": 1}, num_spin_orbitals=2) ** 0 + maj_op = maj_op.simplify() + targ = MajoranaOp.one() + self.assertEqual(maj_op, targ) + + with self.subTest("square with parameters"): + maj_op = MajoranaOp({"_0 _1 _2": self.a, "_1 _0 _3": 1}, num_spin_orbitals=2) ** 2 + maj_op = maj_op.simplify() + square = (2 * self.a.log()).exp() # qiskit.circuit.Parameter has no pow method + targ = MajoranaOp( + {"": -1 - square, "_2 _3": self.a, "_3 _2": self.a}, num_spin_orbitals=2 + ) + self.assertEqual(maj_op, targ) + + def test_adjoint(self): + """Test adjoint method""" + maj_op = MajoranaOp( + {"": 1j, "_0 _1 _2": 3, "_0 _1 _3": 1, "_1 _3": 2 + 4j}, num_spin_orbitals=3 + ).adjoint() + targ = MajoranaOp( + {"": -1j, "_2 _1 _0": 3, "_3 _1 _0": 1, "_3 _1": 2 - 4j}, num_spin_orbitals=3 + ) + self.assertEqual(maj_op, targ) + + maj_op = MajoranaOp( + {"": 1j, "_0 _1 _2": 3, "_0 _1 _3": self.a, "_1 _3": 2 + 4j}, num_spin_orbitals=3 + ).adjoint() + targ = MajoranaOp( + {"": -1j, "_2 _1 _0": 3, "_3 _1 _0": self.a.conjugate(), "_3 _1": 2 - 4j}, + num_spin_orbitals=3, + ) + self.assertEqual(maj_op, targ) + + def test_simplify(self): + """Test simplify""" + with self.subTest("simplify integer"): + maj_op = MajoranaOp({"_0 _1": 1, "_0 _1 _1 _1": 1}, num_spin_orbitals=1) + simplified_op = maj_op.simplify() + targ = MajoranaOp({"_0 _1": 2}, num_spin_orbitals=1) + self.assertEqual(simplified_op, targ) + + with self.subTest("simplify complex"): + maj_op = MajoranaOp({"_0 _1": 1, "_0 _1 _0 _0": 1j}, num_spin_orbitals=1) + simplified_op = maj_op.simplify() + targ = MajoranaOp({"_0 _1": 1 + 1j}, num_spin_orbitals=1) + self.assertEqual(simplified_op, targ) + + with self.subTest("simplify doesn't reorder"): + maj_op = MajoranaOp({"_1 _2": 1 + 0j}, num_spin_orbitals=2) + simplified_op = maj_op.simplify() + self.assertEqual(simplified_op, maj_op) + + maj_op = MajoranaOp({"_3 _0": 1 + 0j}, num_spin_orbitals=2) + simplified_op = maj_op.simplify() + self.assertEqual(simplified_op, maj_op) + + with self.subTest("simplify zero"): + maj_op = self.op1 - self.op1 + simplified_op = maj_op.simplify() + targ = MajoranaOp.zero() + self.assertEqual(simplified_op, targ) + + with self.subTest("simplify parameters"): + maj_op = MajoranaOp({"_0 _1": self.a, "_0 _1 _0 _0": 1j}) + simplified_op = maj_op.simplify() + targ = MajoranaOp({"_0 _1": self.a + 1j}) + self.assertEqual(simplified_op, targ) + + with self.subTest("simplify + index order"): + orig = MajoranaOp({"_3 _1 _0 _1": 1, "_0 _3": 2}) + maj_op = orig.simplify().index_order() + targ = MajoranaOp({"_0 _3": 3}) + self.assertEqual(maj_op, targ) + + def test_hermiticity(self): + """test is_hermitian""" + with self.subTest("operator hermitian"): + maj_op = ( + 1j * MajoranaOp({"_0 _1 _2 _3": 1}, num_spin_orbitals=2) + - 1j * MajoranaOp({"_3 _2 _1 _0": 1}, num_spin_orbitals=2) + + MajoranaOp({"_0 _1": 1}, num_spin_orbitals=2) + + MajoranaOp({"_1 _0": 1}, num_spin_orbitals=2) + ) + self.assertTrue(maj_op.is_hermitian()) + + with self.subTest("operator not hermitian"): + maj_op = ( + 1j * MajoranaOp({"_0 _1 _2 _3": 1}, num_spin_orbitals=2) + + 1j * MajoranaOp({"_3 _2 _1 _0": 1}, num_spin_orbitals=2) + + MajoranaOp({"_0 _1": 1}, num_spin_orbitals=2) + - MajoranaOp({"_1 _0": 1}, num_spin_orbitals=2) + ) + self.assertFalse(maj_op.is_hermitian()) + + with self.subTest("test passing atol"): + maj_op = MajoranaOp({"_0 _1": 1}, num_spin_orbitals=2) + (1 + 1e-7) * MajoranaOp( + {"_1 _0": 1}, num_spin_orbitals=2 + ) + self.assertFalse(maj_op.is_hermitian()) + self.assertFalse(maj_op.is_hermitian(atol=1e-8)) + self.assertTrue(maj_op.is_hermitian(atol=1e-6)) + + with self.subTest("parameters"): + maj_op = MajoranaOp({"_0": self.a}) + with self.assertRaisesRegex(ValueError, "parameter"): + _ = maj_op.is_hermitian() + + def test_equiv(self): + """test equiv""" + prev_atol = MajoranaOp.atol + prev_rtol = MajoranaOp.rtol + op3 = self.op1 + (1 + 0.00005) * self.op2 + self.assertFalse(op3.equiv(self.op3)) + MajoranaOp.atol = 1e-4 + MajoranaOp.rtol = 1e-4 + self.assertTrue(op3.equiv(self.op3)) + MajoranaOp.atol = prev_atol + MajoranaOp.rtol = prev_rtol + + def test_index_order(self): + """test index_order method""" + ordered_op = MajoranaOp({"_0 _1": 1}) + reverse_op = MajoranaOp({"_1 _0": -1}) + maj_op = ordered_op.index_order() + self.assertEqual(maj_op, ordered_op) + maj_op = reverse_op.index_order() + self.assertEqual(maj_op, ordered_op) + + def test_induced_norm(self): + """Test induced norm.""" + op1 = 3 * MajoranaOp({"_0": 1}, num_spin_orbitals=1) + 4j * MajoranaOp( + {"_1": 1}, num_spin_orbitals=1 + ) + op2 = 3 * MajoranaOp({"_0": 1}, num_spin_orbitals=1) + 4j * MajoranaOp( + {"_0": 1}, num_spin_orbitals=1 + ) + self.assertAlmostEqual(op1.induced_norm(), 7.0) + self.assertAlmostEqual(op1.induced_norm(2), 5.0) + self.assertAlmostEqual(op2.induced_norm(), 5.0) + self.assertAlmostEqual(op2.induced_norm(2), 5.0) + + @unpack + @data( + ("", 1, True), # empty string + ("_0", 1, True), # single term + ("_0 _1", 2, True), # multiple terms + ("_0 _3", 4, True), # multiple orbitals + ("_1 _1", 2, True), # identical terms + ("_10", 11, True), # multiple digits + (" _0", 1, False), # leading whitespace + ("_0 ", 1, False), # trailing whitespace + ("_0 _0", 1, False), # multiple separating spaces + ("_0a", 1, False), # incorrect term pattern + ("_a0", 1, False), # incorrect term pattern + ("0_", 1, False), # incorrect term pattern + ("+_0", 1, False), # incorrect term pattern: fermionic op + ("something", 1, False), # incorrect term pattern + ("_1", 1, True), # 1 spin orbital takes two registers + ("_2", 1, False), # register length is too short + ) + def test_validate(self, key: str, length: int, valid: bool): + """Test key validation.""" + num_so = (length + 1) // 2 + if valid: + _ = MajoranaOp({key: 1.0}, num_spin_orbitals=num_so) + else: + with self.assertRaises(QiskitNatureError): + _ = MajoranaOp({key: 1.0}, num_spin_orbitals=num_so) + + def test_from_polynomial_tensor(self): + """Test from PolynomialTensor construction""" + + with self.subTest("dense tensor"): + p_t = PolynomialTensor( + { + "_": np.arange(1, 3), + "__": np.arange(1, 5).reshape((2, 2)), + } + ) + op = MajoranaOp.from_polynomial_tensor(p_t) + + expected = MajoranaOp( + { + "_0": 1, + "_1": 2, + "_0 _0": 1, + "_0 _1": 2, + "_1 _0": 3, + "_1 _1": 4, + }, + num_spin_orbitals=1, + ) + + self.assertEqual(op, expected) + + if _optionals.HAS_SPARSE: + import sparse as sp # pyright: ignore # pylint: disable=import-error + + with self.subTest("sparse tensor"): + r_l = 2 + p_t = PolynomialTensor( + { + "__": sp.as_coo({(0, 0): 1, (1, 0): 2}, shape=(r_l, r_l)), + "____": sp.as_coo( + {(0, 0, 0, 1): 1, (1, 0, 1, 1): 2}, shape=(r_l, r_l, r_l, r_l) + ), + } + ) + op = MajoranaOp.from_polynomial_tensor(p_t) + + expected = MajoranaOp( + { + "_0 _0": 1, + "_1 _0": 2, + "_0 _0 _0 _1": 1, + "_1 _0 _1 _1": 2, + }, + num_spin_orbitals=r_l, + ) + + self.assertEqual(op, expected) + + with self.subTest("compose operation order"): + r_l = 2 + p_t = PolynomialTensor( + { + "__": np.arange(1, 5).reshape((r_l, r_l)), + "____": np.arange(1, 17).reshape((r_l, r_l, r_l, r_l)), + } + ) + op = MajoranaOp.from_polynomial_tensor(p_t) + + a = op @ op + b = MajoranaOp.from_polynomial_tensor(p_t @ p_t) + self.assertEqual(a, b) + + with self.subTest("tensor operation order"): + r_l = 2 + p_t = PolynomialTensor( + { + "__": np.arange(1, 5).reshape((r_l, r_l)), + "____": np.arange(1, 17).reshape((r_l, r_l, r_l, r_l)), + } + ) + op = MajoranaOp.from_polynomial_tensor(p_t) + + self.assertEqual(op ^ op, MajoranaOp.from_polynomial_tensor(p_t ^ p_t)) + + def test_no_num_spin_orbitals(self): + """Test operators with automatic register length""" + op0 = MajoranaOp({"": 1}) + op1 = MajoranaOp({"_0 _1": 1}) + op2 = MajoranaOp({"_0 _1 _2": 2}) + + with self.subTest("Inferred register length"): + self.assertEqual(op0.num_spin_orbitals, 0) + self.assertEqual(op1.num_spin_orbitals, 1) + self.assertEqual(op2.num_spin_orbitals, 2) + + with self.subTest("Mathematical operations"): + self.assertEqual((op0 + op2).num_spin_orbitals, 2) + self.assertEqual((op1 + op2).num_spin_orbitals, 2) + self.assertEqual((op0 @ op2).num_spin_orbitals, 2) + self.assertEqual((op1 @ op2).num_spin_orbitals, 2) + self.assertEqual((op1 ^ op2).num_spin_orbitals, 3) + + with self.subTest("Equality"): + op3 = MajoranaOp({"_0 _1": 1}, num_spin_orbitals=3) + self.assertEqual(op1, op3) + self.assertTrue(op1.equiv(1.000001 * op3)) + + def test_terms(self): + """Test terms generator.""" + op = MajoranaOp( + { + "_0": 1, + "_0 _1": 2, + "_1 _2 _3": 2, + } + ) + + terms = [([("", 0)], 1), ([("", 0), ("", 1)], 2), ([("", 1), ("", 2), ("", 3)], 2)] + + with self.subTest("terms"): + self.assertEqual(list(op.terms()), terms) + + with self.subTest("from_terms"): + self.assertEqual(MajoranaOp.from_terms(terms), op) + + def test_permute_indices(self): + """Test index permutation method.""" + op = MajoranaOp( + { + "_0 _1": 1, + "_1 _2": 2, + }, + num_spin_orbitals=2, + ) + + with self.subTest("wrong permutation length"): + with self.assertRaises(ValueError): + _ = op.permute_indices([1, 0]) + + with self.subTest("actual permutation"): + permuted_op = op.permute_indices([2, 1, 3, 0]) + + self.assertEqual(permuted_op, MajoranaOp({"_2 _1": 1, "_1 _3": 2}, num_spin_orbitals=2)) + + def test_reg_len_with_skipped_key_validation(self): + """Test the behavior of `register_length` after key validation was skipped.""" + new_op = MajoranaOp({"_0 _1": 1}, validate=False) + self.assertIsNone(new_op.num_spin_orbitals) + self.assertEqual(new_op.register_length, 2) + + def test_from_fermionic_op(self): + """Test conversion from FermionicOp.""" + original_ops = [ + FermionicOp({"+_0 -_1": 1}, num_spin_orbitals=2), + FermionicOp({"+_0 -_0 +_1 -_1": 2}, num_spin_orbitals=2), + FermionicOp({"+_0 +_1 -_2 -_1": 3}, num_spin_orbitals=3), + ] + expected_ops_no_simp_no_order = [ + MajoranaOp( + {"_0 _2": 0.25, "_0 _3": 0.25j, "_1 _2": -0.25j, "_1 _3": 0.25}, num_spin_orbitals=2 + ), + 2 + * MajoranaOp( + { + "_0 _0 _2 _2": 1 / 16, + "_0 _1 _2 _2": 1j / 16, + "_1 _0 _2 _2": -1j / 16, + "_1 _1 _2 _2": 1 / 16, + # + "_0 _0 _2 _3": 1j / 16, + "_0 _1 _2 _3": -1 / 16, + "_1 _0 _2 _3": 1 / 16, + "_1 _1 _2 _3": 1j / 16, + # + "_0 _0 _3 _2": -1j / 16, + "_0 _1 _3 _2": 1 / 16, + "_1 _0 _3 _2": -1 / 16, + "_1 _1 _3 _2": -1j / 16, + # + "_0 _0 _3 _3": 1 / 16, + "_0 _1 _3 _3": 1j / 16, + "_1 _0 _3 _3": -1j / 16, + "_1 _1 _3 _3": 1 / 16, + }, + num_spin_orbitals=2, + ), + 3 + * MajoranaOp( + { + "_0 _2 _4 _2": 1 / 16, + "_0 _3 _4 _2": -1j / 16, + "_1 _2 _4 _2": -1j / 16, + "_1 _3 _4 _2": -1 / 16, + # + "_0 _2 _4 _3": 1j / 16, + "_0 _3 _4 _3": 1 / 16, + "_1 _2 _4 _3": 1 / 16, + "_1 _3 _4 _3": -1j / 16, + # + "_0 _2 _5 _2": 1j / 16, + "_0 _3 _5 _2": 1 / 16, + "_1 _2 _5 _2": 1 / 16, + "_1 _3 _5 _2": -1j / 16, + # + "_0 _2 _5 _3": -1 / 16, + "_0 _3 _5 _3": 1j / 16, + "_1 _2 _5 _3": 1j / 16, + "_1 _3 _5 _3": 1 / 16, + }, + num_spin_orbitals=3, + ), + ] + expected_ops_no_simplify = [ + MajoranaOp( + {"_0 _2": 0.25, "_0 _3": 0.25j, "_1 _2": -0.25j, "_1 _3": 0.25}, num_spin_orbitals=2 + ), + 2 + * MajoranaOp( + { + "_0 _0 _2 _2": 1 / 16, + "_0 _1 _2 _2": 1j / 8, + "_1 _1 _2 _2": 1 / 16, + "_0 _0 _2 _3": 1j / 8, + "_0 _1 _2 _3": -1 / 4, + "_1 _1 _2 _3": 1j / 8, + "_0 _0 _3 _3": 1 / 16, + "_0 _1 _3 _3": 1j / 8, + "_1 _1 _3 _3": 1 / 16, + }, + num_spin_orbitals=2, + ), + 3 + * MajoranaOp( + { + "_0 _2 _2 _4": -1 / 16, + "_0 _2 _3 _4": -1j / 8, + "_1 _2 _2 _4": 1j / 16, + "_1 _2 _3 _4": -1 / 8, + "_0 _3 _3 _4": -1 / 16, + "_1 _3 _3 _4": 1j / 16, + "_0 _2 _2 _5": -1j / 16, + "_0 _2 _3 _5": 1 / 8, + "_1 _2 _2 _5": -1 / 16, + "_1 _2 _3 _5": -1j / 8, + "_0 _3 _3 _5": -1j / 16, + "_1 _3 _3 _5": -1 / 16, + }, + num_spin_orbitals=3, + ), + ] + expected_ops_no_order = [ + MajoranaOp( + {"_0 _2": 0.25, "_0 _3": 0.25j, "_1 _2": -0.25j, "_1 _3": 0.25}, num_spin_orbitals=2 + ), + 2 + * MajoranaOp( + { + "": 1 / 4, + "_0 _1": 1j / 8, + "_1 _0": -1j / 8, + "_2 _3": 1j / 8, + "_0 _1 _2 _3": -1 / 16, + "_1 _0 _2 _3": 1 / 16, + "_3 _2": -1j / 8, + "_0 _1 _3 _2": 1 / 16, + "_1 _0 _3 _2": -1 / 16, + }, + num_spin_orbitals=2, + ), + 3 + * MajoranaOp( + { + "_0 _4": -1 / 8, + "_0 _5": -1j / 8, + "_1 _4": 1j / 8, + "_1 _5": -1 / 8, + # + "_0 _2 _4 _3": 1j / 16, + "_0 _2 _5 _3": -1 / 16, + "_0 _3 _4 _2": -1j / 16, + "_0 _3 _5 _2": 1 / 16, + "_1 _2 _4 _3": 1 / 16, + "_1 _2 _5 _3": 1j / 16, + "_1 _3 _4 _2": -1 / 16, + "_1 _3 _5 _2": -1j / 16, + }, + num_spin_orbitals=3, + ), + ] + expected_ops = [ + MajoranaOp( + {"_0 _2": 0.25, "_0 _3": 0.25j, "_1 _2": -0.25j, "_1 _3": 0.25}, num_spin_orbitals=2 + ), + 2 + * MajoranaOp( + {"": 1 / 4, "_0 _1": 1j / 4, "_2 _3": 1j / 4, "_0 _1 _2 _3": -1 / 4}, + num_spin_orbitals=2, + ), + 3 + * MajoranaOp( + { + "_0 _4": -1 / 8, + "_0 _2 _3 _4": -1j / 8, + "_1 _4": 1j / 8, + "_1 _2 _3 _4": -1 / 8, + "_0 _5": -1j / 8, + "_0 _2 _3 _5": 1 / 8, + "_1 _5": -1 / 8, + "_1 _2 _3 _5": -1j / 8, + }, + num_spin_orbitals=3, + ), + ] + with self.subTest("conversion"): + for f_op, e_op in zip(original_ops, expected_ops): + t_op = MajoranaOp.from_fermionic_op(f_op) + self.assertEqual(t_op, e_op) + + with self.subTest("sum of operators"): + f_op = original_ops[0] + original_ops[1] + e_op = expected_ops[0] + expected_ops[1] + t_op = MajoranaOp.from_fermionic_op(f_op) + self.assertEqual(t_op, e_op) + + with self.subTest("composed operators"): + f_op = original_ops[0] @ original_ops[1] + e_op = expected_ops[0] @ expected_ops[1] + t_op = MajoranaOp.from_fermionic_op(f_op) + e_op_simplified = e_op.index_order().simplify() + t_op_simplified = t_op.index_order().simplify() + self.assertEqual(t_op_simplified, e_op_simplified) + + with self.subTest("tensored operators"): + f_op = original_ops[0] ^ original_ops[1] + e_op = expected_ops[0] ^ expected_ops[1] + t_op = MajoranaOp.from_fermionic_op(f_op) + self.assertEqual(t_op, e_op) + + with self.subTest("no simplify"): + for f_op, e_op in zip(original_ops, expected_ops_no_simplify): + t_op = MajoranaOp.from_fermionic_op(f_op, simplify=False) + self.assertEqual(t_op, e_op) + with self.subTest("no order"): + for f_op, e_op in zip(original_ops, expected_ops_no_order): + t_op = MajoranaOp.from_fermionic_op(f_op, order=False) + self.assertEqual(t_op, e_op) + + with self.subTest("no simplify no order"): + for f_op, e_op in zip(original_ops, expected_ops_no_simp_no_order): + t_op = MajoranaOp.from_fermionic_op(f_op, simplify=False, order=False) + self.assertEqual(t_op, e_op) + + +if __name__ == "__main__": + unittest.main() From 922eca33b6239fd9296bc6113acb10768e17a723 Mon Sep 17 00:00:00 2001 From: grossardt <99898527+grossardt@users.noreply.github.com> Date: Sun, 19 Nov 2023 00:18:10 +0100 Subject: [PATCH 02/22] New style of MajoranaOp.terms(), some new tests --- .../second_q/operators/majorana_op.py | 21 ++++++++++--- test/second_q/operators/test_majorana_op.py | 30 +++++++++++++++++-- 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/qiskit_nature/second_q/operators/majorana_op.py b/qiskit_nature/second_q/operators/majorana_op.py index 4dd035554..1c5edf6fb 100644 --- a/qiskit_nature/second_q/operators/majorana_op.py +++ b/qiskit_nature/second_q/operators/majorana_op.py @@ -40,6 +40,13 @@ class MajoranaOp(SparseLabelOp): number of spin orbitals (``num_spin_orbitals``) of the operator (Note: since Python indices are 0-based, the maximum value an index can take is given by :code:`2 * num_spin_orbitals - 1`). + .. note:: + + For compatibility reasons (e.g. with mappers) :meth:`MajoranaOp.terms()` returns a list of + tuples of the form `("+", index)`, i.e. is compatible with the format for + :class:`FermionicOp` with all operators being treated as creation operators. + When using the :meth:`MajoranaOp.from_terms()` constructor, any label string is accepted. + **Initialization** A ``MajoranaOp`` is initialized with a dictionary, mapping terms to their respective @@ -267,6 +274,11 @@ def _validate_keys(self, keys: Collection[str]) -> None: if num_so is None: self.num_spin_orbitals = (max_index + 1 if max_index % 2 else max_index + 2) // 2 + @staticmethod + def _majorana_label(label: str) -> str: + """Converts a Fermionic label into a Majorana label.""" + return label.replace("+", "").replace("-", "") + @classmethod def _validate_polynomial_tensor_key(cls, keys: Collection[str]) -> None: # PolynomialTensor keys cannot be built from empty string, @@ -323,7 +335,7 @@ def terms(self) -> Iterator[tuple[list[tuple[str, int]], _TCoeff]]: Yields: A tuple with two items; the first one being a list of pairs of the form (char, int) - where char is always an empty string (for compatibility with other SparseLabelOps) and + where char is always '+' (for compatibility with other SparseLabelOps) and the integer corresponds to the mode index on which the operator gets applied; the second item of the returned tuple is the coefficient of this term. """ @@ -333,7 +345,7 @@ def terms(self) -> Iterator[tuple[list[tuple[str, int]], _TCoeff]]: continue # label.split() will return lbl = '_' for each term # lbl[1:] corresponds to the index - terms = [("", int(lbl[1:])) for lbl in label.split()] + terms = [("+", int(lbl[1:])) for lbl in label.split()] yield (terms, self[label]) @classmethod @@ -375,7 +387,8 @@ def from_fermionic_op( new_coeff = 1j**coeff_power * coeff / 2 ** len(terms) if order: trms = next(trm for trm, _ in MajoranaOp({majorana_label: new_coeff}).terms()) - majorana_label, new_coeff = FermionicOp._index_order(trms, new_coeff) + fermion_label, new_coeff = FermionicOp._index_order(trms, new_coeff) + majorana_label = cls._majorana_label(fermion_label) if simplify: majorana_label, new_coeff = cls._simplify_label(majorana_label, new_coeff) data[majorana_label] += new_coeff @@ -450,7 +463,7 @@ def index_order(self) -> MajoranaOp: for terms, coeff in self.terms(): # index ordering is identical to FermionicOp, hence we call classmethod there: label, coeff = FermionicOp._index_order(terms, coeff) - data[label] += coeff + data[self._majorana_label(label)] += coeff # after successful index ordering, we remove all zero coefficients return self._new_instance( diff --git a/test/second_q/operators/test_majorana_op.py b/test/second_q/operators/test_majorana_op.py index 3413a505b..726822d13 100644 --- a/test/second_q/operators/test_majorana_op.py +++ b/test/second_q/operators/test_majorana_op.py @@ -327,7 +327,7 @@ def test_induced_norm(self): ("_0a", 1, False), # incorrect term pattern ("_a0", 1, False), # incorrect term pattern ("0_", 1, False), # incorrect term pattern - ("+_0", 1, False), # incorrect term pattern: fermionic op + ("+_0", 1, False), # incorrect fermionic pattern ("something", 1, False), # incorrect term pattern ("_1", 1, True), # 1 spin orbital takes two registers ("_2", 1, False), # register length is too short @@ -341,6 +341,32 @@ def test_validate(self, key: str, length: int, valid: bool): with self.assertRaises(QiskitNatureError): _ = MajoranaOp({key: 1.0}, num_spin_orbitals=num_so) + def test_no_copy(self): + """Test constructor with copy=False""" + test_dict = {"_0 _1": 1} + op = MajoranaOp(test_dict, copy=False) + test_dict["_0 _1"] = 2 + self.assertEqual(op, MajoranaOp({"_0 _1": 2})) + + def test_no_validate(self): + """Test skipping validation""" + with self.subTest("no validation"): + op = MajoranaOp({"_0 _1": 1}, num_spin_orbitals=1, validate=False) + self.assertEqual(op, MajoranaOp({"_0 _1": 1})) + + with self.subTest("no validation no num_spin_orbitals"): + op = MajoranaOp({"_0 _1": 1}, validate=False) + self.assertEqual(op.num_spin_orbitals, None) + + with self.subTest("no validation with wrong label"): + op = MajoranaOp({"test": 1}, validate=False) + with self.assertRaises(ValueError): + list(op.terms()) + + with self.subTest("no validation with wrong num_spin_orbitals"): + op = MajoranaOp({"_1 _2": 1}, num_spin_orbitals=1, validate=False) + self.assertEqual(MajoranaOp.from_terms(op.terms()).num_spin_orbitals, 2) + def test_from_polynomial_tensor(self): """Test from PolynomialTensor construction""" @@ -453,7 +479,7 @@ def test_terms(self): } ) - terms = [([("", 0)], 1), ([("", 0), ("", 1)], 2), ([("", 1), ("", 2), ("", 3)], 2)] + terms = [([("+", 0)], 1), ([("+", 0), ("+", 1)], 2), ([("+", 1), ("+", 2), ("+", 3)], 2)] with self.subTest("terms"): self.assertEqual(list(op.terms()), terms) From 97625dd298ec2c8488c157bfb6930c2c0c74004e Mon Sep 17 00:00:00 2001 From: grossardt <99898527+grossardt@users.noreply.github.com> Date: Mon, 27 Nov 2023 22:21:53 +0100 Subject: [PATCH 03/22] Update majorana_op.py --- qiskit_nature/second_q/operators/majorana_op.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qiskit_nature/second_q/operators/majorana_op.py b/qiskit_nature/second_q/operators/majorana_op.py index 1c5edf6fb..471b5b3d1 100644 --- a/qiskit_nature/second_q/operators/majorana_op.py +++ b/qiskit_nature/second_q/operators/majorana_op.py @@ -384,7 +384,7 @@ def from_fermionic_op( else: coeff_power += 3 majorana_label += f"_{index}" - new_coeff = 1j**coeff_power * coeff / 2 ** len(terms) + new_coeff = 1j**coeff_power * coeff / 2**len(terms) if order: trms = next(trm for trm, _ in MajoranaOp({majorana_label: new_coeff}).terms()) fermion_label, new_coeff = FermionicOp._index_order(trms, new_coeff) From 4c84cec01125f008ebad2600c84fcff184226c77 Mon Sep 17 00:00:00 2001 From: grossardt <99898527+grossardt@users.noreply.github.com> Date: Mon, 27 Nov 2023 22:38:01 +0100 Subject: [PATCH 04/22] Update majorana_op.py --- qiskit_nature/second_q/operators/majorana_op.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qiskit_nature/second_q/operators/majorana_op.py b/qiskit_nature/second_q/operators/majorana_op.py index 471b5b3d1..1f156778e 100644 --- a/qiskit_nature/second_q/operators/majorana_op.py +++ b/qiskit_nature/second_q/operators/majorana_op.py @@ -384,7 +384,7 @@ def from_fermionic_op( else: coeff_power += 3 majorana_label += f"_{index}" - new_coeff = 1j**coeff_power * coeff / 2**len(terms) + new_coeff = 1j**coeff_power * coeff / (2 ** len(terms)) if order: trms = next(trm for trm, _ in MajoranaOp({majorana_label: new_coeff}).terms()) fermion_label, new_coeff = FermionicOp._index_order(trms, new_coeff) From 700aa591624fc9b574aaf9825548bc0a4a080fae Mon Sep 17 00:00:00 2001 From: grossardt <99898527+grossardt@users.noreply.github.com> Date: Tue, 28 Nov 2023 23:37:12 +0100 Subject: [PATCH 05/22] Some easier reviews done. More will follow. --- .../second_q/operators/fermionic_op.py | 1 - .../second_q/operators/majorana_op.py | 24 ++++++++++--------- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/qiskit_nature/second_q/operators/fermionic_op.py b/qiskit_nature/second_q/operators/fermionic_op.py index 1125d950d..fc50609de 100644 --- a/qiskit_nature/second_q/operators/fermionic_op.py +++ b/qiskit_nature/second_q/operators/fermionic_op.py @@ -539,7 +539,6 @@ def simplify(self, atol: float | None = None) -> FermionicOp: data = defaultdict(complex) # type: dict[str, _TCoeff] # TODO: use parallel_map to make this more efficient (?) - # (if this is done, apply equally to MajoranaOp.simplify()) for label, coeff in self.items(): label, coeff = self._simplify_label(label, coeff) data[label] += coeff diff --git a/qiskit_nature/second_q/operators/majorana_op.py b/qiskit_nature/second_q/operators/majorana_op.py index 1f156778e..3de6e54a1 100644 --- a/qiskit_nature/second_q/operators/majorana_op.py +++ b/qiskit_nature/second_q/operators/majorana_op.py @@ -35,10 +35,10 @@ class MajoranaOp(SparseLabelOp): A ``MajoranaOp`` represents a weighted sum of Majorana fermion operator terms. These terms are encoded as sparse labels, which are strings consisting of a space-separated list of expressions. Each expression must look like :code:`_`, where the :code:`` is a - non-negative integer representing the index of the mode on which the Majorana - creation/annihilation operator is applied. The value of :code:`index` is bound by twice the - number of spin orbitals (``num_spin_orbitals``) of the operator (Note: since Python indices are - 0-based, the maximum value an index can take is given by :code:`2 * num_spin_orbitals - 1`). + non-negative integer representing the index of the mode on which the Majorana operator is + applied. The value of :code:`index` is bound by twice the number of spin orbitals + (``num_spin_orbitals``) of the operator (Note: since Python indices are 0-based, the maximum + value an index can take is given by :code:`2 * num_spin_orbitals - 1`). .. note:: @@ -101,9 +101,9 @@ class MajoranaOp(SparseLabelOp): f_op = FermionicOp({"+_0 -_1": 1}, num_spin_orbitals=2) m_op = MajoranaOp.from_fermionic_op(f_op) - Note that every term of the ``FermionicOp`` will result in :math:`2^n` terms in the - ``MajoranaOp``, where :math:`n` is the number of fermionic modes in the term. The conversion - uses the convention that + Note that each ``FerminonicOp``-term consisting of :math:`n` expressions will result in a + ``MajoranaOp``-term consisting of :math:`2^n` expressions. The conversion uses the convention + that .. math:: @@ -166,9 +166,9 @@ class MajoranaOp(SparseLabelOp): .. note:: - Since Majorana generators are self-adjoined, the adjoint of a ``MajoranaOp`` is the original + Since Majorana operators are self-adjoined, the adjoint of a ``MajoranaOp`` is the original operator with all strings reversed, e.g. :code:`"_0 _1"` becomes :code:`"_1 _0"` in the - example above, and coefficients complex conjugated. + example above, and coefficients become complex conjugated. **Iteration** @@ -450,8 +450,9 @@ def index_order(self) -> MajoranaOp: .. note:: You can use this method to achieve the most aggressive simplification. - :meth:`simplify` does *not* reorder the terms and, thus, cannot deduce - ``_0 _1 _2`` and ``_2 _0 _1 _0 _0`` to be identical labels. + :meth:`simplify` does *not* reorder the terms. For instance, using only :meth:`simplify` + will reduce ``_2 _0 _1 _0 _0`` to ``_2 _0 _1`` but cannot deduce these labels to be + identical to ``_0 _1 _2``. Calling this method will reorder the latter label to ``_0 _0 _0 _1 _2``, after which :meth:`simplify` will be able to correctly collapse these two labels into one. @@ -496,6 +497,7 @@ def simplify(self, atol: float | None = None) -> MajoranaOp: atol = self.atol if atol is None else atol data = defaultdict(complex) # type: dict[str, _TCoeff] + # TODO: use parallel_map to make this more efficient (?) (see FermionicOp) for label, coeff in self.items(): label, coeff = self._simplify_label(label, coeff) data[label] += coeff From 0ea65bf96a4e695a56b44d2cfee270c6b8a2a4b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9?= <99898527+grossardt@users.noreply.github.com> Date: Tue, 28 Nov 2023 23:44:43 +0100 Subject: [PATCH 06/22] Update releasenotes/notes/add-majoranaop-1cbf9d4a1d4c264e.yaml Co-authored-by: Max Rossmannek --- releasenotes/notes/add-majoranaop-1cbf9d4a1d4c264e.yaml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/releasenotes/notes/add-majoranaop-1cbf9d4a1d4c264e.yaml b/releasenotes/notes/add-majoranaop-1cbf9d4a1d4c264e.yaml index 48615b609..9300e0646 100644 --- a/releasenotes/notes/add-majoranaop-1cbf9d4a1d4c264e.yaml +++ b/releasenotes/notes/add-majoranaop-1cbf9d4a1d4c264e.yaml @@ -15,10 +15,6 @@ features: unnecessary. A Majorana operator can be created from a Fermionic operator using the :meth:`~qiskit_nature.second_q.operators.MajoranaOp.from_fermionic_op` class method. other: - - | - The hidden method :meth:`~qiskit_nature.second_q.operators.FermionicOp._index_order` - has been converted to a class method to be able to be called within - :meth:`~qiskit_nature.second_q.operators.MajoranaOp.index_order`. - | Reference to `to_matrix` method in the documentation of :class:`~qiskit_nature.second_q.operators.FermionicOp` has been removed as there is no such From 2e1dd95d49464361cd2970363d07d49e4f98a7bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9?= <99898527+grossardt@users.noreply.github.com> Date: Tue, 28 Nov 2023 23:45:58 +0100 Subject: [PATCH 07/22] Update releasenotes/notes/add-majoranaop-1cbf9d4a1d4c264e.yaml Co-authored-by: Max Rossmannek --- releasenotes/notes/add-majoranaop-1cbf9d4a1d4c264e.yaml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/releasenotes/notes/add-majoranaop-1cbf9d4a1d4c264e.yaml b/releasenotes/notes/add-majoranaop-1cbf9d4a1d4c264e.yaml index 9300e0646..1eff8ea95 100644 --- a/releasenotes/notes/add-majoranaop-1cbf9d4a1d4c264e.yaml +++ b/releasenotes/notes/add-majoranaop-1cbf9d4a1d4c264e.yaml @@ -15,7 +15,3 @@ features: unnecessary. A Majorana operator can be created from a Fermionic operator using the :meth:`~qiskit_nature.second_q.operators.MajoranaOp.from_fermionic_op` class method. other: - - | - Reference to `to_matrix` method in the documentation of - :class:`~qiskit_nature.second_q.operators.FermionicOp` has been removed as there is no such - method. From 60e6473bc35671d20c65553a21fb192200ba8ad0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9?= <99898527+grossardt@users.noreply.github.com> Date: Fri, 1 Dec 2023 21:19:14 +0100 Subject: [PATCH 08/22] Update qiskit_nature/second_q/operators/majorana_op.py Co-authored-by: Max Rossmannek --- qiskit_nature/second_q/operators/majorana_op.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/qiskit_nature/second_q/operators/majorana_op.py b/qiskit_nature/second_q/operators/majorana_op.py index 3de6e54a1..25c19873c 100644 --- a/qiskit_nature/second_q/operators/majorana_op.py +++ b/qiskit_nature/second_q/operators/majorana_op.py @@ -451,9 +451,9 @@ def index_order(self) -> MajoranaOp: You can use this method to achieve the most aggressive simplification. :meth:`simplify` does *not* reorder the terms. For instance, using only :meth:`simplify` - will reduce ``_2 _0 _1 _0 _0`` to ``_2 _0 _1`` but cannot deduce these labels to be + will reduce ``_2 _0 _1 _0 _0`` to ``_2 _0 _1`` but cannot deduce this label to be identical to ``_0 _1 _2``. - Calling this method will reorder the latter label to + Calling this method will reorder the former label to ``_0 _0 _0 _1 _2``, after which :meth:`simplify` will be able to correctly collapse these two labels into one. From d72af532cb22d7d3aa5def8af8531cf0ad8c1fc4 Mon Sep 17 00:00:00 2001 From: grossardt <99898527+grossardt@users.noreply.github.com> Date: Sat, 2 Dec 2023 00:42:42 +0100 Subject: [PATCH 09/22] Reviewed changes --- .../second_q/operators/majorana_op.py | 150 +++++++------- .../add-majoranaop-1cbf9d4a1d4c264e.yaml | 1 - test/second_q/operators/test_majorana_op.py | 189 +++++++++--------- 3 files changed, 172 insertions(+), 168 deletions(-) diff --git a/qiskit_nature/second_q/operators/majorana_op.py b/qiskit_nature/second_q/operators/majorana_op.py index 25c19873c..53b0577bc 100644 --- a/qiskit_nature/second_q/operators/majorana_op.py +++ b/qiskit_nature/second_q/operators/majorana_op.py @@ -36,16 +36,9 @@ class MajoranaOp(SparseLabelOp): These terms are encoded as sparse labels, which are strings consisting of a space-separated list of expressions. Each expression must look like :code:`_`, where the :code:`` is a non-negative integer representing the index of the mode on which the Majorana operator is - applied. The value of :code:`index` is bound by twice the number of spin orbitals - (``num_spin_orbitals``) of the operator (Note: since Python indices are 0-based, the maximum - value an index can take is given by :code:`2 * num_spin_orbitals - 1`). - - .. note:: - - For compatibility reasons (e.g. with mappers) :meth:`MajoranaOp.terms()` returns a list of - tuples of the form `("+", index)`, i.e. is compatible with the format for - :class:`FermionicOp` with all operators being treated as creation operators. - When using the :meth:`MajoranaOp.from_terms()` constructor, any label string is accepted. + applied. The value of :code:`index` is bound by ``num_modes``. Note that, when converting from a + ``FermionicOp`` there are two modes per spin orbital, i.e. ``num_modes`` is + :code:`2 * FermionicOp.num_spin_orbitals - 1` **Initialization** @@ -63,7 +56,7 @@ class MajoranaOp(SparseLabelOp): "_2 _3": -.25j, "_3 _2": .25j, }, - num_spin_orbitals=2, + num_modes=4, ) By default, this way of initializing will create a full copy of the dictionary of coefficients. @@ -80,7 +73,7 @@ class MajoranaOp(SparseLabelOp): op = MajoranaOp( some_big_data, - num_spin_orbitals=2, + num_modes=4, copy=False, ) @@ -113,11 +106,25 @@ class MajoranaOp(SparseLabelOp): where :math:`a_i` and :math:`a_i^\dagger` are the Fermionic annihilation and creation operators and :math:`\gamma_i` the Majorana operators. - .. note:: + **Construction from Polynomial Tensor** + + Using the :meth:`from_polynomial_tensor` constructor method, a ``MajoranaOp`` can be constructed + from a :class:`~.PolynomialTensor`. In this case, the underscore character :code:`_` is the only + allowed character in the keys of the ``PolynomialTensor``. + For example, - When creating a ``MajoranaOp`` from a ``PolynomialTensor`` using - :meth:`from_polynomial_tensor`, the underscore character :code:`_` is the only allowed - character in the keys of the ``PolynomialTensor``. + .. code-block:: python + + p_t = PolynomialTensor( + { + "_": np.arange(1, 3), + "__": np.arange(1, 5).reshape((2, 2)), + } + ) + op = MajoranaOp.from_polynomial_tensor(p_t) + + # op is then + MajoranaOp({'_0': 1, '_1': 2, '_0 _0': 1, '_0 _1': 2, '_1 _0': 3, '_1 _1': 4}, num_modes=2) **Algebra** @@ -129,40 +136,40 @@ class MajoranaOp(SparseLabelOp): .. code-block:: python - MajoranaOp({"_1": 1}, num_spin_orbitals=2) + MajoranaOp({"_0": 1}, num_spin_orbitals=2) + MajoranaOp({"_1": 1}, num_modes=2) + MajoranaOp({"_0": 1}, num_modes=2) Sum .. code-block:: python - sum(MajoranaOp({label: 1}, num_spin_orbitals=3) for label in ["_0", "_1", "_2 _3"]) + sum(MajoranaOp({label: 1}, num_modes=4) for label in ["_0", "_1", "_2 _3"]) Scalar multiplication .. code-block:: python - 0.5 * MajoranaOp({"_1": 1}, num_spin_orbitals=2) + 0.5 * MajoranaOp({"_1": 1}, num_modes=2) Operator multiplication .. code-block:: python - op1 = MajoranaOp({"_0 _1": 1}, num_spin_orbitals=2) - op2 = MajoranaOp({"_0 _1 _2": 1}, num_spin_orbitals=2) + op1 = MajoranaOp({"_0 _1": 1}, num_modes=3) + op2 = MajoranaOp({"_0 _1 _2": 1}, num_modes=3) print(op1 @ op2) Tensor multiplication .. code-block:: python - op = MajoranaOp({"_0 _1": 1}, num_spin_orbitals=2) + op = MajoranaOp({"_0 _1": 1}, num_modes=2) print(op ^ op) Adjoint .. code-block:: python - MajoranaOp({"_0 _1": 1j}, num_spin_orbitals=2).adjoint() + MajoranaOp({"_0 _1": 1j}, num_modes=2).adjoint() .. note:: @@ -176,10 +183,11 @@ class MajoranaOp(SparseLabelOp): pairs describing the terms contained in the operator. Attributes: - num_spin_orbitals (int | None): the number of spin orbitals on which this operator acts. + num_modes (int | None): the number of modes on which this operator acts. This is considered a lower bound, which means that mathematical operations acting on two - or more operators will result in a new operator with the maximum number of spin orbitals - of any of the involved operators. + or more operators will result in a new operator with the maximum number of modes of any + of the involved operators. + When converting from a ``FermionicOp``, this is twice the number of spin orbitals. .. note:: @@ -195,6 +203,7 @@ class MajoranaOp(SparseLabelOp): def __init__( self, data: Mapping[str, _TCoeff], + num_modes: int | None = None, num_spin_orbitals: int | None = None, *, copy: bool = True, @@ -203,7 +212,10 @@ def __init__( """ Args: data: the operator data, mapping string-based keys to numerical values. - num_spin_orbitals: the number of spin orbitals on which this operator acts. + num_modes: the number of modes on which this operator acts. + num_spin_orbitals: the number of spin orbitals. Providing :code:`num_spin_orbitals=n` + is equivalent to providing :code:`num_modes=2*n`. Ignored if ``num_modes`` is + provided. copy: when set to False the ``data`` will not be copied and the dictionary will be stored by reference rather than by value (which is the default; ``copy=True``). Note, that this requires you to not change the contents of the dictionary after @@ -216,37 +228,36 @@ def __init__( Raises: QiskitNatureError: when an invalid key is encountered during validation. """ - self.num_spin_orbitals = num_spin_orbitals - # if num_spin_orbitals is None, it is set during _validate_keys + if num_modes is None and num_spin_orbitals is not None: + num_modes = num_spin_orbitals * 2 + self.num_modes = num_modes + # if num_modes is None, it is set during _validate_keys super().__init__(data, copy=copy, validate=validate) @property def register_length(self) -> int: - if self.num_spin_orbitals is None: + if self.num_modes is None: max_index = max(int(term[1:]) for key in self._data for term in key.split()) - if max_index % 2 == 0: - max_index += 1 return max_index + 1 - - return 2 * self.num_spin_orbitals + return self.num_modes def _new_instance( self, data: Mapping[str, _TCoeff], *, other: MajoranaOp | None = None ) -> MajoranaOp: - num_so = self.num_spin_orbitals + num_modes = self.num_modes if other is not None: - other_num_so = other.num_spin_orbitals - if num_so is None: - num_so = other_num_so - elif other_num_so is not None: - num_so = max(num_so, other_num_so) + other_num_modes = other.num_modes + if num_modes is None: + num_modes = other_num_modes + elif other_num_modes is not None: + num_modes = max(num_modes, other_num_modes) - return self.__class__(data, copy=False, num_spin_orbitals=num_so) + return self.__class__(data, copy=False, num_modes=num_modes) def _validate_keys(self, keys: Collection[str]) -> None: super()._validate_keys(keys) - num_so = self.num_spin_orbitals + num_modes = self.num_modes max_index = -1 @@ -262,22 +273,17 @@ def _validate_keys(self, keys: Collection[str]) -> None: # 2. validate all indices against register length for term in key.split(): index = int(term[1:]) - if num_so is None: + if num_modes is None: if index > max_index: max_index = index - elif index >= 2 * num_so: + elif index >= num_modes: raise QiskitNatureError( - f"The index, {index}, from the label, {key}, exceeds twice the number of " - f"spin orbitals, {num_so}." + f"The index, {index}, from the label, {key}, exceeds the number of " + f"modes, {num_modes}." ) - if num_so is None: - self.num_spin_orbitals = (max_index + 1 if max_index % 2 else max_index + 2) // 2 - - @staticmethod - def _majorana_label(label: str) -> str: - """Converts a Fermionic label into a Majorana label.""" - return label.replace("+", "").replace("-", "") + if num_modes is None: + self.num_modes = max_index + 1 @classmethod def _validate_polynomial_tensor_key(cls, keys: Collection[str]) -> None: @@ -311,19 +317,16 @@ def from_polynomial_tensor(cls, tensor: PolynomialTensor) -> MajoranaOp: for value, index in mat.coord_iter(): data[label_template.format(*index)] = value - num_so = (tensor.register_length + 1) // 2 - return cls(data, copy=False, num_spin_orbitals=num_so).chop() + num_modes = tensor.register_length + return cls(data, copy=False, num_modes=num_modes).chop() def __repr__(self) -> str: data_str = f"{dict(self.items())}" - return "MajoranaOp(" f"{data_str}, " f"num_spin_orbitals={self.num_spin_orbitals}, " ")" + return "MajoranaOp(" f"{data_str}, " f"num_modes={self.num_modes}, " ")" def __str__(self) -> str: - pre = ( - "Majorana Operator\n" - f"number spin orbitals={self.num_spin_orbitals}, number terms={len(self)}\n" - ) + pre = "Majorana Operator\n" f"number modes={self.num_modes}, number terms={len(self)}\n" ret = " " + "\n+ ".join( [f"{coeff} * ( {label} )" if label else f"{coeff}" for label, coeff in self.items()] ) @@ -334,8 +337,8 @@ def terms(self) -> Iterator[tuple[list[tuple[str, int]], _TCoeff]]: pairs of operation characters and indices. Yields: - A tuple with two items; the first one being a list of pairs of the form (char, int) - where char is always '+' (for compatibility with other SparseLabelOps) and + A tuple with two items; the first one being a list of pairs of the form ('', int) + where the empty string is for compatibility with other :class:`SparseLabelOp` and the integer corresponds to the mode index on which the operator gets applied; the second item of the returned tuple is the coefficient of this term. """ @@ -345,7 +348,7 @@ def terms(self) -> Iterator[tuple[list[tuple[str, int]], _TCoeff]]: continue # label.split() will return lbl = '_' for each term # lbl[1:] corresponds to the index - terms = [("+", int(lbl[1:])) for lbl in label.split()] + terms = [("", int(lbl[1:])) for lbl in label.split()] yield (terms, self[label]) @classmethod @@ -354,15 +357,12 @@ def from_terms(cls, terms: Sequence[tuple[list[tuple[str, int]], _TCoeff]]) -> M return cls(data) @classmethod - def from_fermionic_op( - cls, op: FermionicOp, simplify: bool = True, order: bool = True - ) -> MajoranaOp: + def from_fermionic_op(cls, op: FermionicOp, simplify: bool = True) -> MajoranaOp: """Constructs the operator from a :class:`~.FermionicOp`. Args: op: the :class:`~.FermionicOp` to convert. - simplify: whether to simplify the resulting operator. - order: whether to perform index ordering on the resulting operator. + simplify: whether to index order and simplify the resulting operator. Returns: The converted :class:`~.MajoranaOp`. @@ -385,14 +385,12 @@ def from_fermionic_op( coeff_power += 3 majorana_label += f"_{index}" new_coeff = 1j**coeff_power * coeff / (2 ** len(terms)) - if order: - trms = next(trm for trm, _ in MajoranaOp({majorana_label: new_coeff}).terms()) - fermion_label, new_coeff = FermionicOp._index_order(trms, new_coeff) - majorana_label = cls._majorana_label(fermion_label) if simplify: + trms = next(trm for trm, _ in MajoranaOp({majorana_label: new_coeff}).terms()) + majorana_label, new_coeff = FermionicOp._index_order(trms, new_coeff) majorana_label, new_coeff = cls._simplify_label(majorana_label, new_coeff) data[majorana_label] += new_coeff - return cls(data, num_spin_orbitals=op.num_spin_orbitals) + return cls(data, num_modes=2 * op.num_spin_orbitals) def _permute_term( self, term: list[tuple[str, int]], permutation: Sequence[int] @@ -418,7 +416,7 @@ def expand(self, other: MajoranaOp) -> MajoranaOp: @classmethod def _tensor(cls, a: MajoranaOp, b: MajoranaOp, *, offset: bool = True) -> MajoranaOp: - shift = 2 * a.num_spin_orbitals if offset else 0 + shift = a.num_modes if offset else 0 new_data: dict[str, _TCoeff] = {} for label1, cf1 in a.items(): @@ -431,7 +429,7 @@ def _tensor(cls, a: MajoranaOp, b: MajoranaOp, *, offset: bool = True) -> Majora new_op = a._new_instance(new_data, other=b) if offset: - new_op.num_spin_orbitals = a.num_spin_orbitals + b.num_spin_orbitals + new_op.num_modes = a.num_modes + b.num_modes return new_op def transpose(self) -> MajoranaOp: @@ -464,7 +462,7 @@ def index_order(self) -> MajoranaOp: for terms, coeff in self.terms(): # index ordering is identical to FermionicOp, hence we call classmethod there: label, coeff = FermionicOp._index_order(terms, coeff) - data[self._majorana_label(label)] += coeff + data[label] += coeff # after successful index ordering, we remove all zero coefficients return self._new_instance( diff --git a/releasenotes/notes/add-majoranaop-1cbf9d4a1d4c264e.yaml b/releasenotes/notes/add-majoranaop-1cbf9d4a1d4c264e.yaml index 1eff8ea95..9a587f143 100644 --- a/releasenotes/notes/add-majoranaop-1cbf9d4a1d4c264e.yaml +++ b/releasenotes/notes/add-majoranaop-1cbf9d4a1d4c264e.yaml @@ -14,4 +14,3 @@ features: :class:`~qiskit_nature.second_q.operators.FermionicOp` except for normal ordering, which is unnecessary. A Majorana operator can be created from a Fermionic operator using the :meth:`~qiskit_nature.second_q.operators.MajoranaOp.from_fermionic_op` class method. -other: diff --git a/test/second_q/operators/test_majorana_op.py b/test/second_q/operators/test_majorana_op.py index 726822d13..e8b41a011 100644 --- a/test/second_q/operators/test_majorana_op.py +++ b/test/second_q/operators/test_majorana_op.py @@ -40,7 +40,7 @@ class TestMajoranaOp(QiskitNatureTestCase): def test_neg(self): """Test __neg__""" maj_op = -self.op1 - targ = MajoranaOp({"_0 _1": -1}, num_spin_orbitals=1) + targ = MajoranaOp({"_0 _1": -1}, num_modes=2) self.assertEqual(maj_op, targ) maj_op = -self.op4 @@ -51,7 +51,7 @@ def test_mul(self): """Test __mul__, and __rmul__""" with self.subTest("rightmul"): maj_op = self.op1 * 2 - targ = MajoranaOp({"_0 _1": 2}, num_spin_orbitals=1) + targ = MajoranaOp({"_0 _1": 2}, num_modes=2) self.assertEqual(maj_op, targ) maj_op = self.op1 * self.a @@ -60,13 +60,13 @@ def test_mul(self): with self.subTest("left mul"): maj_op = (2 + 1j) * self.op3 - targ = MajoranaOp({"_0 _1": (2 + 1j), "_1 _0": (4 + 2j)}, num_spin_orbitals=1) + targ = MajoranaOp({"_0 _1": (2 + 1j), "_1 _0": (4 + 2j)}, num_modes=2) self.assertEqual(maj_op, targ) def test_div(self): """Test __truediv__""" maj_op = self.op1 / 2 - targ = MajoranaOp({"_0 _1": 0.5}, num_spin_orbitals=1) + targ = MajoranaOp({"_0 _1": 0.5}, num_modes=2) self.assertEqual(maj_op, targ) maj_op = self.op1 / self.a @@ -91,7 +91,7 @@ def test_add(self): def test_sub(self): """Test __sub__""" maj_op = self.op3 - self.op2 - targ = MajoranaOp({"_0 _1": 1, "_1 _0": 0}, num_spin_orbitals=1) + targ = MajoranaOp({"_0 _1": 1, "_1 _0": 0}, num_modes=2) self.assertEqual(maj_op, targ) maj_op = self.op4 - self.op1 @@ -101,10 +101,8 @@ def test_sub(self): def test_compose(self): """Test operator composition""" with self.subTest("single compose"): - maj_op = MajoranaOp({"_0 _2": 1}, num_spin_orbitals=2) @ MajoranaOp( - {"_1": 1}, num_spin_orbitals=2 - ) - targ = MajoranaOp({"_0 _2 _1": 1}, num_spin_orbitals=2) + maj_op = MajoranaOp({"_0 _2": 1}, num_modes=4) @ MajoranaOp({"_1": 1}, num_modes=4) + targ = MajoranaOp({"_0 _2 _1": 1}, num_modes=4) self.assertEqual(maj_op, targ) with self.subTest("single compose with parameters"): @@ -113,13 +111,13 @@ def test_compose(self): self.assertEqual(maj_op, targ) with self.subTest("multi compose"): - maj_op = MajoranaOp({"_0 _2 _3": 1, "_1 _2 _3": 1}, num_spin_orbitals=2) @ MajoranaOp( - {"": 1, "_1 _3": 1}, num_spin_orbitals=2 + maj_op = MajoranaOp({"_0 _2 _3": 1, "_1 _2 _3": 1}, num_modes=4) @ MajoranaOp( + {"": 1, "_1 _3": 1}, num_modes=4 ) maj_op = maj_op.simplify() targ = MajoranaOp( {"_0 _2 _3": 1, "_1 _2 _3": 1, "_0 _2 _1": -1, "_2": 1}, - num_spin_orbitals=2, + num_modes=4, ) self.assertEqual(maj_op, targ) @@ -141,7 +139,7 @@ def test_compose(self): def test_tensor(self): """Test tensor multiplication""" maj_op = self.op1.tensor(self.op2) - targ = MajoranaOp({"_0 _1 _3 _2": 2}, num_spin_orbitals=2) + targ = MajoranaOp({"_0 _1 _3 _2": 2}, num_modes=4) self.assertEqual(maj_op, targ) maj_op = self.op4.tensor(self.op2) @@ -151,7 +149,7 @@ def test_tensor(self): def test_expand(self): """Test reversed tensor multiplication""" maj_op = self.op1.expand(self.op2) - targ = MajoranaOp({"_1 _0 _2 _3": 2}, num_spin_orbitals=2) + targ = MajoranaOp({"_1 _0 _2 _3": 2}, num_modes=4) self.assertEqual(maj_op, targ) maj_op = self.op4.expand(self.op2) @@ -161,9 +159,9 @@ def test_expand(self): def test_pow(self): """Test __pow__""" with self.subTest("square"): - maj_op = MajoranaOp({"_0 _1 _2": 3, "_1 _0 _3": 1}, num_spin_orbitals=2) ** 2 + maj_op = MajoranaOp({"_0 _1 _2": 3, "_1 _0 _3": 1}, num_modes=4) ** 2 maj_op = maj_op.simplify() - targ = MajoranaOp({"": -10, "_2 _3": 3, "_3 _2": 3}, num_spin_orbitals=2) + targ = MajoranaOp({"": -10, "_2 _3": 3, "_3 _2": 3}, num_modes=4) self.assertEqual(maj_op, targ) with self.subTest("3rd power"): @@ -172,59 +170,55 @@ def test_pow(self): self.assertEqual(maj_op, targ) with self.subTest("0th power"): - maj_op = MajoranaOp({"_0 _1 _2": 3, "_1 _0 _3": 1}, num_spin_orbitals=2) ** 0 + maj_op = MajoranaOp({"_0 _1 _2": 3, "_1 _0 _3": 1}, num_modes=4) ** 0 maj_op = maj_op.simplify() targ = MajoranaOp.one() self.assertEqual(maj_op, targ) with self.subTest("square with parameters"): - maj_op = MajoranaOp({"_0 _1 _2": self.a, "_1 _0 _3": 1}, num_spin_orbitals=2) ** 2 + maj_op = MajoranaOp({"_0 _1 _2": self.a, "_1 _0 _3": 1}, num_modes=4) ** 2 maj_op = maj_op.simplify() square = (2 * self.a.log()).exp() # qiskit.circuit.Parameter has no pow method - targ = MajoranaOp( - {"": -1 - square, "_2 _3": self.a, "_3 _2": self.a}, num_spin_orbitals=2 - ) + targ = MajoranaOp({"": -1 - square, "_2 _3": self.a, "_3 _2": self.a}, num_modes=4) self.assertEqual(maj_op, targ) def test_adjoint(self): """Test adjoint method""" maj_op = MajoranaOp( - {"": 1j, "_0 _1 _2": 3, "_0 _1 _3": 1, "_1 _3": 2 + 4j}, num_spin_orbitals=3 + {"": 1j, "_0 _1 _2": 3, "_0 _1 _3": 1, "_1 _3": 2 + 4j}, num_modes=6 ).adjoint() - targ = MajoranaOp( - {"": -1j, "_2 _1 _0": 3, "_3 _1 _0": 1, "_3 _1": 2 - 4j}, num_spin_orbitals=3 - ) + targ = MajoranaOp({"": -1j, "_2 _1 _0": 3, "_3 _1 _0": 1, "_3 _1": 2 - 4j}, num_modes=6) self.assertEqual(maj_op, targ) maj_op = MajoranaOp( - {"": 1j, "_0 _1 _2": 3, "_0 _1 _3": self.a, "_1 _3": 2 + 4j}, num_spin_orbitals=3 + {"": 1j, "_0 _1 _2": 3, "_0 _1 _3": self.a, "_1 _3": 2 + 4j}, num_modes=6 ).adjoint() targ = MajoranaOp( {"": -1j, "_2 _1 _0": 3, "_3 _1 _0": self.a.conjugate(), "_3 _1": 2 - 4j}, - num_spin_orbitals=3, + num_modes=6, ) self.assertEqual(maj_op, targ) def test_simplify(self): """Test simplify""" with self.subTest("simplify integer"): - maj_op = MajoranaOp({"_0 _1": 1, "_0 _1 _1 _1": 1}, num_spin_orbitals=1) + maj_op = MajoranaOp({"_0 _1": 1, "_0 _1 _1 _1": 1}, num_modes=2) simplified_op = maj_op.simplify() - targ = MajoranaOp({"_0 _1": 2}, num_spin_orbitals=1) + targ = MajoranaOp({"_0 _1": 2}, num_modes=2) self.assertEqual(simplified_op, targ) with self.subTest("simplify complex"): - maj_op = MajoranaOp({"_0 _1": 1, "_0 _1 _0 _0": 1j}, num_spin_orbitals=1) + maj_op = MajoranaOp({"_0 _1": 1, "_0 _1 _0 _0": 1j}, num_modes=2) simplified_op = maj_op.simplify() - targ = MajoranaOp({"_0 _1": 1 + 1j}, num_spin_orbitals=1) + targ = MajoranaOp({"_0 _1": 1 + 1j}, num_modes=2) self.assertEqual(simplified_op, targ) with self.subTest("simplify doesn't reorder"): - maj_op = MajoranaOp({"_1 _2": 1 + 0j}, num_spin_orbitals=2) + maj_op = MajoranaOp({"_1 _2": 1 + 0j}, num_modes=4) simplified_op = maj_op.simplify() self.assertEqual(simplified_op, maj_op) - maj_op = MajoranaOp({"_3 _0": 1 + 0j}, num_spin_orbitals=2) + maj_op = MajoranaOp({"_3 _0": 1 + 0j}, num_modes=4) simplified_op = maj_op.simplify() self.assertEqual(simplified_op, maj_op) @@ -250,25 +244,25 @@ def test_hermiticity(self): """test is_hermitian""" with self.subTest("operator hermitian"): maj_op = ( - 1j * MajoranaOp({"_0 _1 _2 _3": 1}, num_spin_orbitals=2) - - 1j * MajoranaOp({"_3 _2 _1 _0": 1}, num_spin_orbitals=2) - + MajoranaOp({"_0 _1": 1}, num_spin_orbitals=2) - + MajoranaOp({"_1 _0": 1}, num_spin_orbitals=2) + 1j * MajoranaOp({"_0 _1 _2 _3": 1}, num_modes=4) + - 1j * MajoranaOp({"_3 _2 _1 _0": 1}, num_modes=4) + + MajoranaOp({"_0 _1": 1}, num_modes=4) + + MajoranaOp({"_1 _0": 1}, num_modes=4) ) self.assertTrue(maj_op.is_hermitian()) with self.subTest("operator not hermitian"): maj_op = ( - 1j * MajoranaOp({"_0 _1 _2 _3": 1}, num_spin_orbitals=2) - + 1j * MajoranaOp({"_3 _2 _1 _0": 1}, num_spin_orbitals=2) - + MajoranaOp({"_0 _1": 1}, num_spin_orbitals=2) - - MajoranaOp({"_1 _0": 1}, num_spin_orbitals=2) + 1j * MajoranaOp({"_0 _1 _2 _3": 1}, num_modes=4) + + 1j * MajoranaOp({"_3 _2 _1 _0": 1}, num_modes=4) + + MajoranaOp({"_0 _1": 1}, num_modes=4) + - MajoranaOp({"_1 _0": 1}, num_modes=4) ) self.assertFalse(maj_op.is_hermitian()) with self.subTest("test passing atol"): - maj_op = MajoranaOp({"_0 _1": 1}, num_spin_orbitals=2) + (1 + 1e-7) * MajoranaOp( - {"_1 _0": 1}, num_spin_orbitals=2 + maj_op = MajoranaOp({"_0 _1": 1}, num_modes=4) + (1 + 1e-7) * MajoranaOp( + {"_1 _0": 1}, num_modes=4 ) self.assertFalse(maj_op.is_hermitian()) self.assertFalse(maj_op.is_hermitian(atol=1e-8)) @@ -292,7 +286,7 @@ def test_equiv(self): MajoranaOp.rtol = prev_rtol def test_index_order(self): - """test index_order method""" + """Test index_order method""" ordered_op = MajoranaOp({"_0 _1": 1}) reverse_op = MajoranaOp({"_1 _0": -1}) maj_op = ordered_op.index_order() @@ -300,14 +294,19 @@ def test_index_order(self): maj_op = reverse_op.index_order() self.assertEqual(maj_op, ordered_op) + def test_index_order_simplify_example(self): + """Test that _2 _0 _1 _0 _0 equals _0 _1 _2 only after index_order""" + op = MajoranaOp({"_2 _0 _1 _0 _0": 1}) + op1 = op.simplify() + op2 = op.index_order().simplify() + self.assertEqual(op1, MajoranaOp({"_2 _0 _1": 1})) + self.assertNotEqual(op1, MajoranaOp({"_0 _1 _2": 1})) + self.assertEqual(op2, MajoranaOp({"_0 _1 _2": 1})) + def test_induced_norm(self): """Test induced norm.""" - op1 = 3 * MajoranaOp({"_0": 1}, num_spin_orbitals=1) + 4j * MajoranaOp( - {"_1": 1}, num_spin_orbitals=1 - ) - op2 = 3 * MajoranaOp({"_0": 1}, num_spin_orbitals=1) + 4j * MajoranaOp( - {"_0": 1}, num_spin_orbitals=1 - ) + op1 = 3 * MajoranaOp({"_0": 1}, num_modes=2) + 4j * MajoranaOp({"_1": 1}, num_modes=2) + op2 = 3 * MajoranaOp({"_0": 1}, num_modes=2) + 4j * MajoranaOp({"_0": 1}, num_modes=2) self.assertAlmostEqual(op1.induced_norm(), 7.0) self.assertAlmostEqual(op1.induced_norm(2), 5.0) self.assertAlmostEqual(op2.induced_norm(), 5.0) @@ -329,17 +328,16 @@ def test_induced_norm(self): ("0_", 1, False), # incorrect term pattern ("+_0", 1, False), # incorrect fermionic pattern ("something", 1, False), # incorrect term pattern - ("_1", 1, True), # 1 spin orbital takes two registers - ("_2", 1, False), # register length is too short + ("_1", 2, True), # 1 spin orbital takes two registers + ("_2", 2, False), # register length is too short ) def test_validate(self, key: str, length: int, valid: bool): """Test key validation.""" - num_so = (length + 1) // 2 if valid: - _ = MajoranaOp({key: 1.0}, num_spin_orbitals=num_so) + _ = MajoranaOp({key: 1.0}, num_modes=length) else: with self.assertRaises(QiskitNatureError): - _ = MajoranaOp({key: 1.0}, num_spin_orbitals=num_so) + _ = MajoranaOp({key: 1.0}, num_modes=length) def test_no_copy(self): """Test constructor with copy=False""" @@ -351,21 +349,22 @@ def test_no_copy(self): def test_no_validate(self): """Test skipping validation""" with self.subTest("no validation"): - op = MajoranaOp({"_0 _1": 1}, num_spin_orbitals=1, validate=False) + op = MajoranaOp({"_0 _1": 1}, num_modes=2, validate=False) self.assertEqual(op, MajoranaOp({"_0 _1": 1})) - with self.subTest("no validation no num_spin_orbitals"): + with self.subTest("no validation no num_modes"): op = MajoranaOp({"_0 _1": 1}, validate=False) - self.assertEqual(op.num_spin_orbitals, None) + self.assertEqual(op.num_modes, None) with self.subTest("no validation with wrong label"): op = MajoranaOp({"test": 1}, validate=False) with self.assertRaises(ValueError): list(op.terms()) - with self.subTest("no validation with wrong num_spin_orbitals"): - op = MajoranaOp({"_1 _2": 1}, num_spin_orbitals=1, validate=False) - self.assertEqual(MajoranaOp.from_terms(op.terms()).num_spin_orbitals, 2) + with self.subTest("no validation with wrong num_modes"): + op = MajoranaOp({"_1 _2": 1}, num_modes=2, validate=False) + op2 = MajoranaOp.from_terms(op.terms()) + self.assertEqual(op2.num_modes, 3) def test_from_polynomial_tensor(self): """Test from PolynomialTensor construction""" @@ -388,7 +387,7 @@ def test_from_polynomial_tensor(self): "_1 _0": 3, "_1 _1": 4, }, - num_spin_orbitals=1, + num_modes=2, ) self.assertEqual(op, expected) @@ -415,7 +414,7 @@ def test_from_polynomial_tensor(self): "_0 _0 _0 _1": 1, "_1 _0 _1 _1": 2, }, - num_spin_orbitals=r_l, + num_modes=r_l, ) self.assertEqual(op, expected) @@ -446,29 +445,35 @@ def test_from_polynomial_tensor(self): self.assertEqual(op ^ op, MajoranaOp.from_polynomial_tensor(p_t ^ p_t)) - def test_no_num_spin_orbitals(self): + def test_no_num_modes(self): """Test operators with automatic register length""" op0 = MajoranaOp({"": 1}) op1 = MajoranaOp({"_0 _1": 1}) op2 = MajoranaOp({"_0 _1 _2": 2}) with self.subTest("Inferred register length"): - self.assertEqual(op0.num_spin_orbitals, 0) - self.assertEqual(op1.num_spin_orbitals, 1) - self.assertEqual(op2.num_spin_orbitals, 2) + self.assertEqual(op0.num_modes, 0) + self.assertEqual(op1.num_modes, 2) + self.assertEqual(op2.num_modes, 3) with self.subTest("Mathematical operations"): - self.assertEqual((op0 + op2).num_spin_orbitals, 2) - self.assertEqual((op1 + op2).num_spin_orbitals, 2) - self.assertEqual((op0 @ op2).num_spin_orbitals, 2) - self.assertEqual((op1 @ op2).num_spin_orbitals, 2) - self.assertEqual((op1 ^ op2).num_spin_orbitals, 3) + self.assertEqual((op0 + op2).num_modes, 3) + self.assertEqual((op1 + op2).num_modes, 3) + self.assertEqual((op0 @ op2).num_modes, 3) + self.assertEqual((op1 @ op2).num_modes, 3) + self.assertEqual((op1 ^ op2).num_modes, 5) with self.subTest("Equality"): - op3 = MajoranaOp({"_0 _1": 1}, num_spin_orbitals=3) + op3 = MajoranaOp({"_0 _1": 1}, num_modes=6) self.assertEqual(op1, op3) self.assertTrue(op1.equiv(1.000001 * op3)) + def test_creation_with_spin_orbitals(self): + """Test creation with spin orbitals.""" + op1 = MajoranaOp({"_0 _1": 1}, num_spin_orbitals=1) + op2 = MajoranaOp({"_0 _1": 1}, num_modes=2) + self.assertEqual(op1, op2) + def test_terms(self): """Test terms generator.""" op = MajoranaOp( @@ -479,7 +484,7 @@ def test_terms(self): } ) - terms = [([("+", 0)], 1), ([("+", 0), ("+", 1)], 2), ([("+", 1), ("+", 2), ("+", 3)], 2)] + terms = [([("", 0)], 1), ([("", 0), ("", 1)], 2), ([("", 1), ("", 2), ("", 3)], 2)] with self.subTest("terms"): self.assertEqual(list(op.terms()), terms) @@ -494,7 +499,7 @@ def test_permute_indices(self): "_0 _1": 1, "_1 _2": 2, }, - num_spin_orbitals=2, + num_modes=4, ) with self.subTest("wrong permutation length"): @@ -504,12 +509,12 @@ def test_permute_indices(self): with self.subTest("actual permutation"): permuted_op = op.permute_indices([2, 1, 3, 0]) - self.assertEqual(permuted_op, MajoranaOp({"_2 _1": 1, "_1 _3": 2}, num_spin_orbitals=2)) + self.assertEqual(permuted_op, MajoranaOp({"_2 _1": 1, "_1 _3": 2}, num_modes=4)) def test_reg_len_with_skipped_key_validation(self): """Test the behavior of `register_length` after key validation was skipped.""" new_op = MajoranaOp({"_0 _1": 1}, validate=False) - self.assertIsNone(new_op.num_spin_orbitals) + self.assertIsNone(new_op.num_modes) self.assertEqual(new_op.register_length, 2) def test_from_fermionic_op(self): @@ -521,7 +526,7 @@ def test_from_fermionic_op(self): ] expected_ops_no_simp_no_order = [ MajoranaOp( - {"_0 _2": 0.25, "_0 _3": 0.25j, "_1 _2": -0.25j, "_1 _3": 0.25}, num_spin_orbitals=2 + {"_0 _2": 0.25, "_0 _3": 0.25j, "_1 _2": -0.25j, "_1 _3": 0.25}, num_modes=4 ), 2 * MajoranaOp( @@ -546,7 +551,7 @@ def test_from_fermionic_op(self): "_1 _0 _3 _3": -1j / 16, "_1 _1 _3 _3": 1 / 16, }, - num_spin_orbitals=2, + num_modes=4, ), 3 * MajoranaOp( @@ -571,12 +576,12 @@ def test_from_fermionic_op(self): "_1 _2 _5 _3": 1j / 16, "_1 _3 _5 _3": 1 / 16, }, - num_spin_orbitals=3, + num_modes=6, ), ] expected_ops_no_simplify = [ MajoranaOp( - {"_0 _2": 0.25, "_0 _3": 0.25j, "_1 _2": -0.25j, "_1 _3": 0.25}, num_spin_orbitals=2 + {"_0 _2": 0.25, "_0 _3": 0.25j, "_1 _2": -0.25j, "_1 _3": 0.25}, num_modes=4 ), 2 * MajoranaOp( @@ -591,7 +596,7 @@ def test_from_fermionic_op(self): "_0 _1 _3 _3": 1j / 8, "_1 _1 _3 _3": 1 / 16, }, - num_spin_orbitals=2, + num_modes=4, ), 3 * MajoranaOp( @@ -609,12 +614,12 @@ def test_from_fermionic_op(self): "_0 _3 _3 _5": -1j / 16, "_1 _3 _3 _5": -1 / 16, }, - num_spin_orbitals=3, + num_modes=6, ), ] expected_ops_no_order = [ MajoranaOp( - {"_0 _2": 0.25, "_0 _3": 0.25j, "_1 _2": -0.25j, "_1 _3": 0.25}, num_spin_orbitals=2 + {"_0 _2": 0.25, "_0 _3": 0.25j, "_1 _2": -0.25j, "_1 _3": 0.25}, num_modes=4 ), 2 * MajoranaOp( @@ -629,7 +634,7 @@ def test_from_fermionic_op(self): "_0 _1 _3 _2": 1 / 16, "_1 _0 _3 _2": -1 / 16, }, - num_spin_orbitals=2, + num_modes=4, ), 3 * MajoranaOp( @@ -648,17 +653,17 @@ def test_from_fermionic_op(self): "_1 _3 _4 _2": -1 / 16, "_1 _3 _5 _2": -1j / 16, }, - num_spin_orbitals=3, + num_modes=6, ), ] expected_ops = [ MajoranaOp( - {"_0 _2": 0.25, "_0 _3": 0.25j, "_1 _2": -0.25j, "_1 _3": 0.25}, num_spin_orbitals=2 + {"_0 _2": 0.25, "_0 _3": 0.25j, "_1 _2": -0.25j, "_1 _3": 0.25}, num_modes=4 ), 2 * MajoranaOp( {"": 1 / 4, "_0 _1": 1j / 4, "_2 _3": 1j / 4, "_0 _1 _2 _3": -1 / 4}, - num_spin_orbitals=2, + num_modes=4, ), 3 * MajoranaOp( @@ -672,7 +677,7 @@ def test_from_fermionic_op(self): "_1 _5": -1 / 8, "_1 _2 _3 _5": -1j / 8, }, - num_spin_orbitals=3, + num_modes=6, ), ] with self.subTest("conversion"): @@ -703,15 +708,17 @@ def test_from_fermionic_op(self): with self.subTest("no simplify"): for f_op, e_op in zip(original_ops, expected_ops_no_simplify): t_op = MajoranaOp.from_fermionic_op(f_op, simplify=False) + t_op = t_op.index_order() self.assertEqual(t_op, e_op) with self.subTest("no order"): for f_op, e_op in zip(original_ops, expected_ops_no_order): - t_op = MajoranaOp.from_fermionic_op(f_op, order=False) + t_op = MajoranaOp.from_fermionic_op(f_op, simplify=False) + t_op = t_op.simplify() self.assertEqual(t_op, e_op) with self.subTest("no simplify no order"): for f_op, e_op in zip(original_ops, expected_ops_no_simp_no_order): - t_op = MajoranaOp.from_fermionic_op(f_op, simplify=False, order=False) + t_op = MajoranaOp.from_fermionic_op(f_op, simplify=False) self.assertEqual(t_op, e_op) From d23cb86822041dca066b8af9603a85865d0a68e1 Mon Sep 17 00:00:00 2001 From: grossardt <99898527+grossardt@users.noreply.github.com> Date: Mon, 11 Dec 2023 23:08:32 +0100 Subject: [PATCH 10/22] Implement ModeBasedMapper no caching --- qiskit_nature/second_q/mappers/__init__.py | 3 + .../second_q/mappers/bravyi_kitaev_mapper.py | 9 +- .../second_q/mappers/direct_mapper.py | 9 +- .../second_q/mappers/jordan_wigner_mapper.py | 9 +- .../second_q/mappers/mode_based_mapper.py | 142 ++++++++++++++++++ .../second_q/mappers/parity_mapper.py | 11 +- .../second_q/mappers/qubit_mapper.py | 114 +------------- .../improve-mappers-b55cb0ca5fd656e4.yaml | 15 ++ 8 files changed, 176 insertions(+), 136 deletions(-) create mode 100644 qiskit_nature/second_q/mappers/mode_based_mapper.py create mode 100644 releasenotes/notes/improve-mappers-b55cb0ca5fd656e4.yaml diff --git a/qiskit_nature/second_q/mappers/__init__.py b/qiskit_nature/second_q/mappers/__init__.py index 9d98b9515..796952359 100644 --- a/qiskit_nature/second_q/mappers/__init__.py +++ b/qiskit_nature/second_q/mappers/__init__.py @@ -25,6 +25,7 @@ :nosignatures: QubitMapper + ModeBasedMapper FermionicOp Mappers +++++++++++++++++++ @@ -101,6 +102,7 @@ from .logarithmic_mapper import LogarithmicMapper from .direct_mapper import DirectMapper from .qubit_mapper import QubitMapper +from .mode_based_mapper import ModeBasedMapper from .interleaved_qubit_mapper import InterleavedQubitMapper from .tapered_qubit_mapper import TaperedQubitMapper @@ -116,4 +118,5 @@ "QubitMapper", "InterleavedQubitMapper", "TaperedQubitMapper", + "ModeBasedMapper", ] diff --git a/qiskit_nature/second_q/mappers/bravyi_kitaev_mapper.py b/qiskit_nature/second_q/mappers/bravyi_kitaev_mapper.py index 957f579f4..14b32d4cf 100644 --- a/qiskit_nature/second_q/mappers/bravyi_kitaev_mapper.py +++ b/qiskit_nature/second_q/mappers/bravyi_kitaev_mapper.py @@ -14,21 +14,18 @@ from __future__ import annotations -from functools import lru_cache - import numpy as np from qiskit.quantum_info.operators import Pauli from .fermionic_mapper import FermionicMapper +from .mode_based_mapper import ModeBasedMapper, PauliType -class BravyiKitaevMapper(FermionicMapper): +class BravyiKitaevMapper(FermionicMapper, ModeBasedMapper): """The Bravyi-Kitaev fermion-to-qubit mapping.""" - @classmethod - @lru_cache(maxsize=32) - def pauli_table(cls, register_length: int) -> list[tuple[Pauli, Pauli]]: + def pauli_table(self, register_length: int) -> list[tuple[PauliType, PauliType]]: # pylint: disable=unused-argument def parity_set(j, n): """ diff --git a/qiskit_nature/second_q/mappers/direct_mapper.py b/qiskit_nature/second_q/mappers/direct_mapper.py index 1902ea135..83b928e98 100644 --- a/qiskit_nature/second_q/mappers/direct_mapper.py +++ b/qiskit_nature/second_q/mappers/direct_mapper.py @@ -14,25 +14,22 @@ from __future__ import annotations -from functools import lru_cache - import numpy as np from qiskit.quantum_info.operators import Pauli from .vibrational_mapper import VibrationalMapper +from .mode_based_mapper import ModeBasedMapper, PauliType -class DirectMapper(VibrationalMapper): +class DirectMapper(VibrationalMapper, ModeBasedMapper): """The Direct mapper. This mapper maps a :class:`~.VibrationalOp` to a qubit operator. In doing so, each modal of the ``VibrationalOp`` gets mapped to a single qubit. """ - @classmethod - @lru_cache(maxsize=32) - def pauli_table(cls, register_length: int) -> list[tuple[Pauli, Pauli]]: + def pauli_table(self, register_length: int) -> list[tuple[PauliType, PauliType]]: # pylint: disable=unused-argument pauli_table = [] diff --git a/qiskit_nature/second_q/mappers/jordan_wigner_mapper.py b/qiskit_nature/second_q/mappers/jordan_wigner_mapper.py index 569d35f58..143333363 100644 --- a/qiskit_nature/second_q/mappers/jordan_wigner_mapper.py +++ b/qiskit_nature/second_q/mappers/jordan_wigner_mapper.py @@ -14,21 +14,18 @@ from __future__ import annotations -from functools import lru_cache - import numpy as np from qiskit.quantum_info.operators import Pauli from .fermionic_mapper import FermionicMapper +from .mode_based_mapper import ModeBasedMapper, PauliType -class JordanWignerMapper(FermionicMapper): +class JordanWignerMapper(FermionicMapper, ModeBasedMapper): """The Jordan-Wigner fermion-to-qubit mapping.""" - @classmethod - @lru_cache(maxsize=32) - def pauli_table(cls, register_length: int) -> list[tuple[Pauli, Pauli]]: + def pauli_table(self, register_length: int) -> list[tuple[PauliType, PauliType]]: # pylint: disable=unused-argument pauli_table = [] diff --git a/qiskit_nature/second_q/mappers/mode_based_mapper.py b/qiskit_nature/second_q/mappers/mode_based_mapper.py new file mode 100644 index 000000000..cf68ffe2a --- /dev/null +++ b/qiskit_nature/second_q/mappers/mode_based_mapper.py @@ -0,0 +1,142 @@ +# This code is part of a Qiskit project. +# +# (C) Copyright IBM 2023. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +"""Mode Based Mapper.""" + +from __future__ import annotations + +from abc import abstractmethod + +import numpy as np +from qiskit.quantum_info.operators import Pauli, PauliList, SparsePauliOp + +from qiskit_nature import QiskitNatureError +from qiskit_nature.second_q.operators import SparseLabelOp +from qiskit_nature.second_q.mappers.qubit_mapper import QubitMapper + +# Types that can be data for a SparsePauliOp +PauliType = PauliList | SparsePauliOp | Pauli | list | str + + +class ModeBasedMapper(QubitMapper): + """Mapper from ``SparseLabelOp`` to a qubit operator using a Pauli table.""" + + def _map_single( + self, second_q_op: SparseLabelOp, *, register_length: int | None = None + ) -> SparsePauliOp: + return self.mode_based_mapping(second_q_op, register_length=register_length) + + @abstractmethod + def pauli_table(self, register_length: int) -> list[tuple[PauliType, PauliType]]: + r"""Generates a Pauli-lookup table mapping from modes to Pauli operators or pairs of Pauli + operators. + + This table is a list of tuples :math:`(P, Q)` of two Pauli operators, corresponding to the + real part :math:`P` and imaginary part :math:`Q` for the respective mode index. These Pauli + operators are used to construct the creation and annihilation operators + :math:`(P \pm i Q)/2`. + + The generated table is processed by :meth:`.QubitMapper.sparse_pauli_operators`. + + Args: + register_length: the register length for which to generate the table. + + Returns: + A list of tuples of two Pauli string operators. + """ + + def sparse_pauli_operators( + self, register_length: int + ) -> tuple[list[SparsePauliOp], list[SparsePauliOp]]: + # pylint: disable=unused-argument + """Generates the :class:`.SparsePauliOp` terms. + + This uses :meth:`.QubitMapper.pauli_table` to construct a list of operators used to + translate the second-quantization symbols into qubit operators. + + Args: + register_length: the register length for which to generate the operators. + + Returns: + Two lists stored in a tuple, consisting of the creation and annihilation operators, + applied on the individual modes. + """ + times_creation_op = [] + times_annihilation_op = [] + + for paulis in self.pauli_table(register_length): + real_part = SparsePauliOp(paulis[0], coeffs=[0.5]) + imag_part = SparsePauliOp(paulis[1], coeffs=[0.5j]) + + # The creation operator is given by 0.5*(X - 1j*Y) + creation_op = real_part - imag_part + times_creation_op.append(creation_op) + + # The annihilation operator is given by 0.5*(X + 1j*Y) + annihilation_op = real_part + imag_part + times_annihilation_op.append(annihilation_op) + + return (times_creation_op, times_annihilation_op) + + def mode_based_mapping( + self, + second_q_op: SparseLabelOp, + register_length: int | None = None, + ) -> SparsePauliOp: + # pylint: disable=unused-argument + """Utility method to map a ``SparseLabelOp`` to a qubit operator using a pauli table. + + Args: + second_q_op: the `SparseLabelOp` to be mapped. + register_length: when provided, this will be used to overwrite the ``register_length`` + attribute of the operator being mapped. This is possible because the + ``register_length`` is considered a lower bound. + + Returns: + The qubit operator corresponding to the problem-Hamiltonian in the qubit space. + + Raises: + QiskitNatureError: If number length of pauli table does not match the number + of operator modes, or if the operator has unexpected label content + """ + if register_length is None: + register_length = second_q_op.register_length + + times_creation_op, times_annihilation_op = self.sparse_pauli_operators(register_length) + + # make sure ret_op_list is not empty by including a zero op + ret_op_list = [SparsePauliOp("I" * register_length, coeffs=[0])] + + for terms, coeff in second_q_op.terms(): + # 1. Initialize an operator list with the identity scaled by the `coeff` + ret_op = SparsePauliOp("I" * register_length, coeffs=np.array([coeff])) + + # Go through the label and replace the fermion operators by their qubit-equivalent, then + # save the respective Pauli string in the pauli_str list. + for term in terms: + char = term[0] + if char == "": + break + position = int(term[1]) + if char == "+": + ret_op = ret_op.compose(times_creation_op[position], front=True).simplify() + elif char == "-": + ret_op = ret_op.compose(times_annihilation_op[position], front=True).simplify() + # catch any disallowed labels + else: + raise QiskitNatureError( + f"FermionicOp label included '{char}'. Allowed characters: I, N, E, +, -" + ) + ret_op_list.append(ret_op) + + sparse_op = SparsePauliOp.sum(ret_op_list).simplify() + return sparse_op diff --git a/qiskit_nature/second_q/mappers/parity_mapper.py b/qiskit_nature/second_q/mappers/parity_mapper.py index 78d7912ba..cde23d136 100644 --- a/qiskit_nature/second_q/mappers/parity_mapper.py +++ b/qiskit_nature/second_q/mappers/parity_mapper.py @@ -16,8 +16,6 @@ import logging -from functools import lru_cache - import numpy as np from qiskit.quantum_info.analysis.z2_symmetries import Z2Symmetries @@ -25,11 +23,12 @@ from qiskit_nature.second_q.operators import FermionicOp from .fermionic_mapper import FermionicMapper +from .mode_based_mapper import ModeBasedMapper, PauliType logger = logging.getLogger(__name__) -class ParityMapper(FermionicMapper): +class ParityMapper(FermionicMapper, ModeBasedMapper): """The Parity fermion-to-qubit mapping. When using this mapper, :attr:`num_particles` can optionally be used to apply an additional step @@ -72,9 +71,7 @@ def num_particles(self, value: tuple[int, int] | None) -> None: par_2 = 1 if num_alpha % 2 == 0 else -1 self._tapering_values = [par_2, par_1] - @classmethod - @lru_cache(maxsize=32) - def pauli_table(cls, register_length: int) -> list[tuple[Pauli, Pauli]]: + def pauli_table(self, register_length: int) -> list[tuple[PauliType, PauliType]]: # pylint: disable=unused-argument pauli_table = [] @@ -129,7 +126,7 @@ def _two_qubit_reduce(self, operator: SparsePauliOp) -> SparsePauliOp: def _map_single( self, second_q_op: FermionicOp, *, register_length: int | None = None ) -> SparsePauliOp: - mapped_op = ParityMapper.mode_based_mapping(second_q_op, register_length=register_length) + mapped_op = self.mode_based_mapping(second_q_op, register_length=register_length) reduced_op = mapped_op if self.num_particles is not None: diff --git a/qiskit_nature/second_q/mappers/qubit_mapper.py b/qiskit_nature/second_q/mappers/qubit_mapper.py index 42926671e..98a792011 100644 --- a/qiskit_nature/second_q/mappers/qubit_mapper.py +++ b/qiskit_nature/second_q/mappers/qubit_mapper.py @@ -14,15 +14,12 @@ from __future__ import annotations -from abc import ABC -from functools import lru_cache +from abc import ABC, abstractmethod from typing import TypeVar, Dict, Iterable, Generic, Generator -import numpy as np -from qiskit.quantum_info.operators import Pauli, SparsePauliOp +from qiskit.quantum_info.operators import SparsePauliOp from qiskit_algorithms.list_or_dict import ListOrDict as ListOrDictType -from qiskit_nature import QiskitNatureError from qiskit_nature.second_q.operators import SparseLabelOp # pylint: disable=invalid-name @@ -122,6 +119,7 @@ class QubitMapper(ABC): qubit operator in the form of a ``SparsePauliOp``. """ + @abstractmethod def _map_single( self, second_q_op: SparseLabelOp, *, register_length: int | None = None ) -> SparsePauliOp: @@ -137,7 +135,6 @@ def _map_single( Returns: The qubit operator corresponding to the problem-Hamiltonian in the qubit space. """ - return self.mode_based_mapping(second_q_op, register_length=register_length) def map( self, @@ -168,108 +165,3 @@ def map( # Note the output of the mapping will never be None for standard mappers other than the # TaperedQubitMapper. return returned_ops - - @classmethod - @lru_cache(maxsize=32) - def pauli_table(cls, register_length: int) -> list[tuple[Pauli, Pauli]]: - """Generates a Pauli-lookup table mapping from modes to pauli pairs. - - The generated table is processed by :meth:`.QubitMapper.sparse_pauli_operators`. - - Args: - register_length: the register length for which to generate the table. - - Returns: - A list of tuples in which the first and second Pauli operator the real and imaginary - Pauli strings, respectively. - """ - - @classmethod - @lru_cache(maxsize=32) - def sparse_pauli_operators( - cls, register_length: int - ) -> tuple[list[SparsePauliOp], list[SparsePauliOp]]: - # pylint: disable=unused-argument - """Generates the cached :class:`.SparsePauliOp` terms. - - This uses :meth:`.QubitMapper.pauli_table` to construct a list of operators used to - translate the second-quantization symbols into qubit operators. - - Args: - register_length: the register length for which to generate the operators. - - Returns: - Two lists stored in a tuple, consisting of the creation and annihilation operators, - applied on the individual modes. - """ - times_creation_op = [] - times_annihilation_op = [] - - for paulis in cls.pauli_table(register_length): - real_part = SparsePauliOp(paulis[0], coeffs=[0.5]) - imag_part = SparsePauliOp(paulis[1], coeffs=[0.5j]) - - # The creation operator is given by 0.5*(X - 1j*Y) - creation_op = real_part - imag_part - times_creation_op.append(creation_op) - - # The annihilation operator is given by 0.5*(X + 1j*Y) - annihilation_op = real_part + imag_part - times_annihilation_op.append(annihilation_op) - - return (times_creation_op, times_annihilation_op) - - @classmethod - def mode_based_mapping( - cls, - second_q_op: SparseLabelOp, - register_length: int | None = None, - ) -> SparsePauliOp: - # pylint: disable=unused-argument - """Utility method to map a ``SparseLabelOp`` to a qubit operator using a pauli table. - - Args: - second_q_op: the `SparseLabelOp` to be mapped. - register_length: when provided, this will be used to overwrite the ``register_length`` - attribute of the operator being mapped. This is possible because the - ``register_length`` is considered a lower bound. - - Returns: - The qubit operator corresponding to the problem-Hamiltonian in the qubit space. - - Raises: - QiskitNatureError: If number length of pauli table does not match the number - of operator modes, or if the operator has unexpected label content - """ - if register_length is None: - register_length = second_q_op.register_length - - times_creation_op, times_annihilation_op = cls.sparse_pauli_operators(register_length) - - # make sure ret_op_list is not empty by including a zero op - ret_op_list = [SparsePauliOp("I" * register_length, coeffs=[0])] - - for terms, coeff in second_q_op.terms(): - # 1. Initialize an operator list with the identity scaled by the `coeff` - ret_op = SparsePauliOp("I" * register_length, coeffs=np.array([coeff])) - - # Go through the label and replace the fermion operators by their qubit-equivalent, then - # save the respective Pauli string in the pauli_str list. - for term in terms: - char = term[0] - if char == "": - break - position = int(term[1]) - if char == "+": - ret_op = ret_op.compose(times_creation_op[position], front=True).simplify() - elif char == "-": - ret_op = ret_op.compose(times_annihilation_op[position], front=True).simplify() - # catch any disallowed labels - else: - raise QiskitNatureError( - f"FermionicOp label included '{char}'. Allowed characters: I, N, E, +, -" - ) - ret_op_list.append(ret_op) - - sparse_op = SparsePauliOp.sum(ret_op_list).simplify() - return sparse_op diff --git a/releasenotes/notes/improve-mappers-b55cb0ca5fd656e4.yaml b/releasenotes/notes/improve-mappers-b55cb0ca5fd656e4.yaml new file mode 100644 index 000000000..995c9a24f --- /dev/null +++ b/releasenotes/notes/improve-mappers-b55cb0ca5fd656e4.yaml @@ -0,0 +1,15 @@ +--- +features: + - | + List new features here, or remove this section. All of the list items in + this section are combined when the release notes are rendered, so the text + needs to be worded so that it does not depend on any information only + available in another section, such as the prelude. This may mean repeating + some details. +upgrade: + - | + List upgrade notes here, or remove this section. All of the list items in + this section are combined when the release notes are rendered, so the text + needs to be worded so that it does not depend on any information only + available in another section, such as the prelude. This may mean repeating + some details. From 3e318ba0ab36daa10040fa3ae2e52e91c6cb419e Mon Sep 17 00:00:00 2001 From: grossardt <99898527+grossardt@users.noreply.github.com> Date: Tue, 12 Dec 2023 21:46:45 +0100 Subject: [PATCH 11/22] Added caching and release note --- .../second_q/mappers/bravyi_kitaev_mapper.py | 8 ++++++- .../second_q/mappers/direct_mapper.py | 8 ++++++- .../second_q/mappers/jordan_wigner_mapper.py | 8 ++++++- .../second_q/mappers/parity_mapper.py | 7 +++++- .../improve-mappers-b55cb0ca5fd656e4.yaml | 22 ++++++++++--------- 5 files changed, 39 insertions(+), 14 deletions(-) diff --git a/qiskit_nature/second_q/mappers/bravyi_kitaev_mapper.py b/qiskit_nature/second_q/mappers/bravyi_kitaev_mapper.py index 14b32d4cf..33e4b4c2e 100644 --- a/qiskit_nature/second_q/mappers/bravyi_kitaev_mapper.py +++ b/qiskit_nature/second_q/mappers/bravyi_kitaev_mapper.py @@ -14,6 +14,8 @@ from __future__ import annotations +from functools import lru_cache + import numpy as np from qiskit.quantum_info.operators import Pauli @@ -26,7 +28,11 @@ class BravyiKitaevMapper(FermionicMapper, ModeBasedMapper): """The Bravyi-Kitaev fermion-to-qubit mapping.""" def pauli_table(self, register_length: int) -> list[tuple[PauliType, PauliType]]: - # pylint: disable=unused-argument + return self._pauli_table(register_length) + + @staticmethod + @lru_cache(maxsize=32) + def _pauli_table(register_length: int) -> list[tuple[PauliType, PauliType]]: def parity_set(j, n): """ Computes the parity set of the j-th orbital in n modes. diff --git a/qiskit_nature/second_q/mappers/direct_mapper.py b/qiskit_nature/second_q/mappers/direct_mapper.py index 83b928e98..ef312426d 100644 --- a/qiskit_nature/second_q/mappers/direct_mapper.py +++ b/qiskit_nature/second_q/mappers/direct_mapper.py @@ -14,6 +14,8 @@ from __future__ import annotations +from functools import lru_cache + import numpy as np from qiskit.quantum_info.operators import Pauli @@ -30,7 +32,11 @@ class DirectMapper(VibrationalMapper, ModeBasedMapper): """ def pauli_table(self, register_length: int) -> list[tuple[PauliType, PauliType]]: - # pylint: disable=unused-argument + return self._pauli_table(register_length) + + @staticmethod + @lru_cache(maxsize=32) + def _pauli_table(register_length: int) -> list[tuple[PauliType, PauliType]]: pauli_table = [] for i in range(register_length): diff --git a/qiskit_nature/second_q/mappers/jordan_wigner_mapper.py b/qiskit_nature/second_q/mappers/jordan_wigner_mapper.py index 143333363..371825afc 100644 --- a/qiskit_nature/second_q/mappers/jordan_wigner_mapper.py +++ b/qiskit_nature/second_q/mappers/jordan_wigner_mapper.py @@ -14,6 +14,8 @@ from __future__ import annotations +from functools import lru_cache + import numpy as np from qiskit.quantum_info.operators import Pauli @@ -26,7 +28,11 @@ class JordanWignerMapper(FermionicMapper, ModeBasedMapper): """The Jordan-Wigner fermion-to-qubit mapping.""" def pauli_table(self, register_length: int) -> list[tuple[PauliType, PauliType]]: - # pylint: disable=unused-argument + return self._pauli_table(register_length) + + @staticmethod + @lru_cache(maxsize=32) + def _pauli_table(register_length: int) -> list[tuple[PauliType, PauliType]]: pauli_table = [] for i in range(register_length): diff --git a/qiskit_nature/second_q/mappers/parity_mapper.py b/qiskit_nature/second_q/mappers/parity_mapper.py index cde23d136..75588bc02 100644 --- a/qiskit_nature/second_q/mappers/parity_mapper.py +++ b/qiskit_nature/second_q/mappers/parity_mapper.py @@ -15,6 +15,7 @@ from __future__ import annotations import logging +from functools import lru_cache import numpy as np @@ -72,7 +73,11 @@ def num_particles(self, value: tuple[int, int] | None) -> None: self._tapering_values = [par_2, par_1] def pauli_table(self, register_length: int) -> list[tuple[PauliType, PauliType]]: - # pylint: disable=unused-argument + return self._pauli_table(register_length) + + @staticmethod + @lru_cache(maxsize=32) + def _pauli_table(register_length: int) -> list[tuple[PauliType, PauliType]]: pauli_table = [] for i in range(register_length): diff --git a/releasenotes/notes/improve-mappers-b55cb0ca5fd656e4.yaml b/releasenotes/notes/improve-mappers-b55cb0ca5fd656e4.yaml index 995c9a24f..ad2229ac7 100644 --- a/releasenotes/notes/improve-mappers-b55cb0ca5fd656e4.yaml +++ b/releasenotes/notes/improve-mappers-b55cb0ca5fd656e4.yaml @@ -1,15 +1,17 @@ --- features: - | - List new features here, or remove this section. All of the list items in - this section are combined when the release notes are rendered, so the text - needs to be worded so that it does not depend on any information only - available in another section, such as the prelude. This may mean repeating - some details. + The class :class:`.second_q.mappers.ModeBasedMapper` has been added to implement mode based + mapping via a Pauli table (previously part of :class:`.second_q.mappers.QubitMapper`). upgrade: - | - List upgrade notes here, or remove this section. All of the list items in - this section are combined when the release notes are rendered, so the text - needs to be worded so that it does not depend on any information only - available in another section, such as the prelude. This may mean repeating - some details. + The mode based mappers :class:`.JordanWignerMapper`, :class:`.BravyiKitaevMapper`, + :class:`.ParityMapper`, and :class:`.DirectMapper` are now inheriting also from the new + class :class:`.ModeBasedMapper` to implement the :meth:`.mode_based_mapping` method. + - | + :meth:`.pauli_table` is now an instance method of all :class:`.ModeBasedMapper` subclasses + (previously a class method of :class:`.QubitMapper`) + - | + Caching has been removed from the parent classes :class:`.QubitMapper` and + :class:`.ModeBasedMapper` and is now implemented exclusively for the :meth:`.pauli_table` + methods in the child classes. From d26891340dc857e6f1d14da41a02d6fa1bb642fd Mon Sep 17 00:00:00 2001 From: grossardt <99898527+grossardt@users.noreply.github.com> Date: Tue, 12 Dec 2023 23:02:58 +0100 Subject: [PATCH 12/22] Update release note to pass spell check --- .../notes/improve-mappers-b55cb0ca5fd656e4.yaml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/releasenotes/notes/improve-mappers-b55cb0ca5fd656e4.yaml b/releasenotes/notes/improve-mappers-b55cb0ca5fd656e4.yaml index ad2229ac7..7c36e0881 100644 --- a/releasenotes/notes/improve-mappers-b55cb0ca5fd656e4.yaml +++ b/releasenotes/notes/improve-mappers-b55cb0ca5fd656e4.yaml @@ -7,11 +7,12 @@ upgrade: - | The mode based mappers :class:`.JordanWignerMapper`, :class:`.BravyiKitaevMapper`, :class:`.ParityMapper`, and :class:`.DirectMapper` are now inheriting also from the new - class :class:`.ModeBasedMapper` to implement the :meth:`.mode_based_mapping` method. + class :class:`.ModeBasedMapper` to implement the :meth:`.ModeBasedMapper.mode_based_mapping` + method. - | - :meth:`.pauli_table` is now an instance method of all :class:`.ModeBasedMapper` subclasses - (previously a class method of :class:`.QubitMapper`) + :meth:`.ModeBasedMapper.pauli_table` is now an instance method of all :class:`.ModeBasedMapper` + subclasses (previously a class method of :class:`.QubitMapper`) - | Caching has been removed from the parent classes :class:`.QubitMapper` and - :class:`.ModeBasedMapper` and is now implemented exclusively for the :meth:`.pauli_table` - methods in the child classes. + :class:`.ModeBasedMapper` and is now implemented exclusively for the + :meth:`.ModeBasedMapper.pauli_table` methods in the child classes. From 61042e09bafe0e5b1e9206358b7f0bf8991dd412 Mon Sep 17 00:00:00 2001 From: grossardt <99898527+grossardt@users.noreply.github.com> Date: Wed, 13 Dec 2023 00:00:19 +0100 Subject: [PATCH 13/22] fix typing --- qiskit_nature/second_q/mappers/logarithmic_mapper.py | 2 +- qiskit_nature/second_q/mappers/mode_based_mapper.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/qiskit_nature/second_q/mappers/logarithmic_mapper.py b/qiskit_nature/second_q/mappers/logarithmic_mapper.py index 6e19411eb..78c33ee68 100644 --- a/qiskit_nature/second_q/mappers/logarithmic_mapper.py +++ b/qiskit_nature/second_q/mappers/logarithmic_mapper.py @@ -143,7 +143,7 @@ def _logarithmic_encoding( op.chop() spin_op_encoding.append(op) - return tuple(spin_op_encoding) + return tuple(spin_op_encoding) # type: ignore def _embed_matrix( self, diff --git a/qiskit_nature/second_q/mappers/mode_based_mapper.py b/qiskit_nature/second_q/mappers/mode_based_mapper.py index cf68ffe2a..22d926c3a 100644 --- a/qiskit_nature/second_q/mappers/mode_based_mapper.py +++ b/qiskit_nature/second_q/mappers/mode_based_mapper.py @@ -14,6 +14,7 @@ from __future__ import annotations +from typing import TypeAlias from abc import abstractmethod import numpy as np @@ -24,7 +25,7 @@ from qiskit_nature.second_q.mappers.qubit_mapper import QubitMapper # Types that can be data for a SparsePauliOp -PauliType = PauliList | SparsePauliOp | Pauli | list | str +PauliType: TypeAlias = PauliList | SparsePauliOp | Pauli | list | str class ModeBasedMapper(QubitMapper): From 1a4b2e09fdf37465623f78e170db4adb40a4e6ee Mon Sep 17 00:00:00 2001 From: grossardt <99898527+grossardt@users.noreply.github.com> Date: Wed, 13 Dec 2023 00:26:02 +0100 Subject: [PATCH 14/22] fix typing for Python <3.10 --- qiskit_nature/second_q/mappers/logarithmic_mapper.py | 2 +- qiskit_nature/second_q/mappers/mode_based_mapper.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/qiskit_nature/second_q/mappers/logarithmic_mapper.py b/qiskit_nature/second_q/mappers/logarithmic_mapper.py index 78c33ee68..87f6f76a7 100644 --- a/qiskit_nature/second_q/mappers/logarithmic_mapper.py +++ b/qiskit_nature/second_q/mappers/logarithmic_mapper.py @@ -143,7 +143,7 @@ def _logarithmic_encoding( op.chop() spin_op_encoding.append(op) - return tuple(spin_op_encoding) # type: ignore + return (spin_op_encoding[0], spin_op_encoding[1], spin_op_encoding[2], spin_op_encoding[3]) def _embed_matrix( self, diff --git a/qiskit_nature/second_q/mappers/mode_based_mapper.py b/qiskit_nature/second_q/mappers/mode_based_mapper.py index 22d926c3a..340685111 100644 --- a/qiskit_nature/second_q/mappers/mode_based_mapper.py +++ b/qiskit_nature/second_q/mappers/mode_based_mapper.py @@ -14,7 +14,7 @@ from __future__ import annotations -from typing import TypeAlias +from typing import Union from abc import abstractmethod import numpy as np @@ -25,7 +25,7 @@ from qiskit_nature.second_q.mappers.qubit_mapper import QubitMapper # Types that can be data for a SparsePauliOp -PauliType: TypeAlias = PauliList | SparsePauliOp | Pauli | list | str +PauliType = Union[PauliList, SparsePauliOp, Pauli, list, str] class ModeBasedMapper(QubitMapper): From 72ffda732d925ca341bfebfab2579a3931ceb0ff Mon Sep 17 00:00:00 2001 From: grossardt <99898527+grossardt@users.noreply.github.com> Date: Wed, 13 Dec 2023 01:55:08 +0100 Subject: [PATCH 15/22] final suggestions by mrossinek --- .../second_q/operators/majorana_op.py | 25 +++++++------------ .../add-majoranaop-1cbf9d4a1d4c264e.yaml | 2 +- test/second_q/operators/test_majorana_op.py | 23 ++++++++++++----- 3 files changed, 27 insertions(+), 23 deletions(-) diff --git a/qiskit_nature/second_q/operators/majorana_op.py b/qiskit_nature/second_q/operators/majorana_op.py index 53b0577bc..9302e61b4 100644 --- a/qiskit_nature/second_q/operators/majorana_op.py +++ b/qiskit_nature/second_q/operators/majorana_op.py @@ -36,8 +36,8 @@ class MajoranaOp(SparseLabelOp): These terms are encoded as sparse labels, which are strings consisting of a space-separated list of expressions. Each expression must look like :code:`_`, where the :code:`` is a non-negative integer representing the index of the mode on which the Majorana operator is - applied. The value of :code:`index` is bound by ``num_modes``. Note that, when converting from a - ``FermionicOp`` there are two modes per spin orbital, i.e. ``num_modes`` is + applied. The maximum value of :code:`index` is bound by ``num_modes``. Note that, when + converting from a ``FermionicOp`` there are two modes per spin orbital, i.e. ``num_modes`` is :code:`2 * FermionicOp.num_spin_orbitals - 1` **Initialization** @@ -86,7 +86,8 @@ class MajoranaOp(SparseLabelOp): **Construction from Fermionic operator** - The default way to construct a ``MajoranaOp`` is from an existing ``FermionicOp``: + As an alternative to the manual construction above, a more convenient way of initializing a + `MajoranaOp` is, to construct it from an existing `FermionicOp`: .. code-block:: python @@ -106,7 +107,7 @@ class MajoranaOp(SparseLabelOp): where :math:`a_i` and :math:`a_i^\dagger` are the Fermionic annihilation and creation operators and :math:`\gamma_i` the Majorana operators. - **Construction from Polynomial Tensor** + **Construction from a ``PolynomialTensor``** Using the :meth:`from_polynomial_tensor` constructor method, a ``MajoranaOp`` can be constructed from a :class:`~.PolynomialTensor`. In this case, the underscore character :code:`_` is the only @@ -179,8 +180,8 @@ class MajoranaOp(SparseLabelOp): **Iteration** - Instances of ``MajoranaOp`` are iterable. Iterating a ``MajoranaOp`` yields (term, coefficient) - pairs describing the terms contained in the operator. + Instances of ``MajoranaOp`` are iterable. Iterating a ``MajoranaOp`` yields + ``(term, coefficient)`` pairs describing the terms contained in the operator. Attributes: num_modes (int | None): the number of modes on which this operator acts. @@ -204,7 +205,6 @@ def __init__( self, data: Mapping[str, _TCoeff], num_modes: int | None = None, - num_spin_orbitals: int | None = None, *, copy: bool = True, validate: bool = True, @@ -213,9 +213,6 @@ def __init__( Args: data: the operator data, mapping string-based keys to numerical values. num_modes: the number of modes on which this operator acts. - num_spin_orbitals: the number of spin orbitals. Providing :code:`num_spin_orbitals=n` - is equivalent to providing :code:`num_modes=2*n`. Ignored if ``num_modes`` is - provided. copy: when set to False the ``data`` will not be copied and the dictionary will be stored by reference rather than by value (which is the default; ``copy=True``). Note, that this requires you to not change the contents of the dictionary after @@ -228,8 +225,6 @@ def __init__( Raises: QiskitNatureError: when an invalid key is encountered during validation. """ - if num_modes is None and num_spin_orbitals is not None: - num_modes = num_spin_orbitals * 2 self.num_modes = num_modes # if num_modes is None, it is set during _validate_keys super().__init__(data, copy=copy, validate=validate) @@ -287,8 +282,6 @@ def _validate_keys(self, keys: Collection[str]) -> None: @classmethod def _validate_polynomial_tensor_key(cls, keys: Collection[str]) -> None: - # PolynomialTensor keys cannot be built from empty string, - # hence we choose _ to be the only allowed character allowed_chars = {"_"} for key in keys: @@ -311,7 +304,7 @@ def from_polynomial_tensor(cls, tensor: PolynomialTensor) -> MajoranaOp: mat = tensor[key] - empty_string_key = ["" for _ in key] # label format for Majorana is just '_' + empty_string_key = [""] * len(key) # label format for Majorana is just '_' label_template = mat.label_template.format(*empty_string_key) for value, index in mat.coord_iter(): @@ -357,7 +350,7 @@ def from_terms(cls, terms: Sequence[tuple[list[tuple[str, int]], _TCoeff]]) -> M return cls(data) @classmethod - def from_fermionic_op(cls, op: FermionicOp, simplify: bool = True) -> MajoranaOp: + def from_fermionic_op(cls, op: FermionicOp, *, simplify: bool = True) -> MajoranaOp: """Constructs the operator from a :class:`~.FermionicOp`. Args: diff --git a/releasenotes/notes/add-majoranaop-1cbf9d4a1d4c264e.yaml b/releasenotes/notes/add-majoranaop-1cbf9d4a1d4c264e.yaml index 9a587f143..63c406d29 100644 --- a/releasenotes/notes/add-majoranaop-1cbf9d4a1d4c264e.yaml +++ b/releasenotes/notes/add-majoranaop-1cbf9d4a1d4c264e.yaml @@ -10,7 +10,7 @@ features: corresponds to :math:`\gamma_0 \gamma_1` where there are twice the number of spin orbitals operators satisfying :math:`\{\gamma_i,\gamma_j\} = 2 \delta_{ij}`. - Methods of :class:`~qiskit_nature.second_q.operators.MajoranaOp` are the same as for + Methods of :class:`~qiskit_nature.second_q.operators.MajoranaOp` follow the same API as for :class:`~qiskit_nature.second_q.operators.FermionicOp` except for normal ordering, which is unnecessary. A Majorana operator can be created from a Fermionic operator using the :meth:`~qiskit_nature.second_q.operators.MajoranaOp.from_fermionic_op` class method. diff --git a/test/second_q/operators/test_majorana_op.py b/test/second_q/operators/test_majorana_op.py index e8b41a011..03654aade 100644 --- a/test/second_q/operators/test_majorana_op.py +++ b/test/second_q/operators/test_majorana_op.py @@ -22,6 +22,7 @@ from qiskit_nature.exceptions import QiskitNatureError from qiskit_nature.second_q.operators import MajoranaOp, FermionicOp, PolynomialTensor +from qiskit_nature.second_q.operators.commutators import anti_commutator import qiskit_nature.optionals as _optionals @@ -37,6 +38,13 @@ class TestMajoranaOp(QiskitNatureTestCase): op3 = MajoranaOp({"_0 _1": 1, "_1 _0": 2}) op4 = MajoranaOp({"_0 _1": a}) + def test_anticommutation_relation(self): + """Test anticommutation relation""" + mop1 = MajoranaOp({"_0": 1}) + mop2 = MajoranaOp({"_0": 1}) + + self.assertTrue(anti_commutator(mop1, mop2).equiv(MajoranaOp({"": 2}))) + def test_neg(self): """Test __neg__""" maj_op = -self.op1 @@ -468,12 +476,6 @@ def test_no_num_modes(self): self.assertEqual(op1, op3) self.assertTrue(op1.equiv(1.000001 * op3)) - def test_creation_with_spin_orbitals(self): - """Test creation with spin orbitals.""" - op1 = MajoranaOp({"_0 _1": 1}, num_spin_orbitals=1) - op2 = MajoranaOp({"_0 _1": 1}, num_modes=2) - self.assertEqual(op1, op2) - def test_terms(self): """Test terms generator.""" op = MajoranaOp( @@ -721,6 +723,15 @@ def test_from_fermionic_op(self): t_op = MajoranaOp.from_fermionic_op(f_op, simplify=False) self.assertEqual(t_op, e_op) + def test_index_ordering_commutes(self): + """Test that index ordering before vs after conversion from FermionicOp to MajoranaOp + yields same result.""" + fop = FermionicOp({"+_2 -_0 +_1": 1.0}) + self.assertFalse(fop.equiv(fop.index_order())) + mop1 = MajoranaOp.from_fermionic_op(fop).index_order() + mop2 = MajoranaOp.from_fermionic_op(fop.index_order()) + self.assertTrue(mop1.equiv(mop2)) + if __name__ == "__main__": unittest.main() From 89f9114bd2f10f58cff67aa5078afdaa30c81eaa Mon Sep 17 00:00:00 2001 From: grossardt <99898527+grossardt@users.noreply.github.com> Date: Sun, 28 Jan 2024 01:40:06 +0100 Subject: [PATCH 16/22] Add TernaryTreeMapper --- qiskit_nature/second_q/mappers/__init__.py | 10 + .../second_q/mappers/bosonic_linear_mapper.py | 2 +- .../second_q/mappers/majorana_mapper.py | 33 ++++ .../second_q/mappers/mode_based_mapper.py | 9 +- .../second_q/mappers/ternary_tree_mapper.py | 118 ++++++++++++ ...-ternary-tree-mapper-dad9b02ae4af54d2.yaml | 8 + .../mappers/test_ternary_tree_mapper.py | 182 ++++++++++++++++++ 7 files changed, 356 insertions(+), 6 deletions(-) create mode 100644 qiskit_nature/second_q/mappers/majorana_mapper.py create mode 100644 qiskit_nature/second_q/mappers/ternary_tree_mapper.py create mode 100644 releasenotes/notes/add-ternary-tree-mapper-dad9b02ae4af54d2.yaml create mode 100644 test/second_q/mappers/test_ternary_tree_mapper.py diff --git a/qiskit_nature/second_q/mappers/__init__.py b/qiskit_nature/second_q/mappers/__init__.py index 796952359..055f9230f 100644 --- a/qiskit_nature/second_q/mappers/__init__.py +++ b/qiskit_nature/second_q/mappers/__init__.py @@ -38,6 +38,14 @@ BravyiKitaevSuperFastMapper JordanWignerMapper ParityMapper + TernaryTreeMapper + +**Note:** :class:`~qiskit_nature.second_q.mappers.TernaryTreeMapper` maps +:class:`~qiskit_nature.second_q.operators.MajoranaOp` to +:class:`~qiskit.quantum_info.SparsePauliOp`. In order to use it on a +:class:`~qiskit_nature.second_q.operators.FermionicOp`, convert to a +:class:`~qiskit_nature.second_q.operators.MajoranaOp` first using +:code:`MajoranaOp.from_fermionic_op()`. **Interleaved Qubit-Ordering:** If you want to generate qubit operators where the alpha-spin and beta-spin components are mapped to the qubit register in an interleaved (rather than the default @@ -97,6 +105,7 @@ from .bravyi_kitaev_mapper import BravyiKitaevMapper from .jordan_wigner_mapper import JordanWignerMapper from .parity_mapper import ParityMapper +from .ternary_tree_mapper import TernaryTreeMapper from .linear_mapper import LinearMapper from .bosonic_linear_mapper import BosonicLinearMapper from .logarithmic_mapper import LogarithmicMapper @@ -112,6 +121,7 @@ "DirectMapper", "JordanWignerMapper", "ParityMapper", + "TernaryTreeMapper", "LinearMapper", "BosonicLinearMapper", "LogarithmicMapper", diff --git a/qiskit_nature/second_q/mappers/bosonic_linear_mapper.py b/qiskit_nature/second_q/mappers/bosonic_linear_mapper.py index e366968c7..a06794d3d 100644 --- a/qiskit_nature/second_q/mappers/bosonic_linear_mapper.py +++ b/qiskit_nature/second_q/mappers/bosonic_linear_mapper.py @@ -29,7 +29,7 @@ class BosonicLinearMapper(BosonicMapper): """The Linear boson-to-qubit mapping. This mapper generates a linear encoding of the Bosonic operator :math:`b_k^\\dagger, b_k` to qubit - operators (linear combinations of pauli strings). + operators (linear combinations of Pauli strings). In this linear encoding each bosonic mode is represented via :math:`n_k^{max} + 1` qubits, where :math:`n_k^{max}` is the max occupation of the mode (meaning the number of states used in the expansion of the mode, or equivalently the state at which the maximum excitation can take place). diff --git a/qiskit_nature/second_q/mappers/majorana_mapper.py b/qiskit_nature/second_q/mappers/majorana_mapper.py new file mode 100644 index 000000000..0f142878e --- /dev/null +++ b/qiskit_nature/second_q/mappers/majorana_mapper.py @@ -0,0 +1,33 @@ +# This code is part of a Qiskit project. +# +# (C) Copyright IBM 2023. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +"""Majorana Mapper.""" + +from __future__ import annotations + +from qiskit.quantum_info import SparsePauliOp + +from qiskit_nature.second_q.operators import MajoranaOp + +from .qubit_mapper import ListOrDictType, QubitMapper + + +class MajoranaMapper(QubitMapper): + """Mapper of Majorana Operator to Qubit Operator""" + + def map( + self, + second_q_ops: MajoranaOp | ListOrDictType[MajoranaOp], + *, + register_length: int | None = None, + ) -> SparsePauliOp | ListOrDictType[SparsePauliOp]: + return super().map(second_q_ops, register_length=register_length) diff --git a/qiskit_nature/second_q/mappers/mode_based_mapper.py b/qiskit_nature/second_q/mappers/mode_based_mapper.py index 340685111..d25fd4d6b 100644 --- a/qiskit_nature/second_q/mappers/mode_based_mapper.py +++ b/qiskit_nature/second_q/mappers/mode_based_mapper.py @@ -113,22 +113,21 @@ def mode_based_mapping( register_length = second_q_op.register_length times_creation_op, times_annihilation_op = self.sparse_pauli_operators(register_length) + mapped_string_length = times_creation_op[0].num_qubits # make sure ret_op_list is not empty by including a zero op - ret_op_list = [SparsePauliOp("I" * register_length, coeffs=[0])] + ret_op_list = [SparsePauliOp("I" * mapped_string_length, coeffs=[0])] for terms, coeff in second_q_op.terms(): # 1. Initialize an operator list with the identity scaled by the `coeff` - ret_op = SparsePauliOp("I" * register_length, coeffs=np.array([coeff])) + ret_op = SparsePauliOp("I" * mapped_string_length, coeffs=np.array([coeff])) # Go through the label and replace the fermion operators by their qubit-equivalent, then # save the respective Pauli string in the pauli_str list. for term in terms: char = term[0] - if char == "": - break position = int(term[1]) - if char == "+": + if char in ("+", ""): # "" for MajoranaOp, creator = annihiliator ret_op = ret_op.compose(times_creation_op[position], front=True).simplify() elif char == "-": ret_op = ret_op.compose(times_annihilation_op[position], front=True).simplify() diff --git a/qiskit_nature/second_q/mappers/ternary_tree_mapper.py b/qiskit_nature/second_q/mappers/ternary_tree_mapper.py new file mode 100644 index 000000000..8c8bad671 --- /dev/null +++ b/qiskit_nature/second_q/mappers/ternary_tree_mapper.py @@ -0,0 +1,118 @@ +# This code is part of a Qiskit project. +# +# (C) Copyright IBM 2023. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +"""Ternary Tree Mapper.""" + +from __future__ import annotations + +from functools import lru_cache + +from qiskit.quantum_info.operators import SparsePauliOp + +from .majorana_mapper import MajoranaMapper +from .mode_based_mapper import ModeBasedMapper, PauliType + + +class TernaryTreeMapper(MajoranaMapper, ModeBasedMapper): + """Ternary Tree fermion-to-qubit mapping. + + As described by Jiang, Kalev, Mruczkiewicz, and Neven, Quantum 4, 276 (2020), + preprint at `arXiv:1910.10746 `_. + + This is a mapper for :class:`~qiskit_nature.second_q.operators.MajoranaOp`. + For mappping :class:`~qiskit_nature.second_q.operators.FermionicOp` convert + to a Majorana operator first: + + .. code-block:: + + from qiskit_nature.second_q.operators import FermionicOp, MajoranaOp + from qiskit_nature.second_q.mappers import TernaryTreeMapper + fermionic_op = FermionicOp(...) + majorana_op = MajoranaOp.from_fermionic_op(fermionic_op) + majorana_op = majorana_op.index_order().simplify() + pauli_op = TernaryTreeMapper().map(majorana_op) + + """ + + _pauli_priority: str = "ZXY" + + def __init__(self, pauli_priority: str = "ZXY") -> TernaryTreeMapper: + """ + Use the Pauli priority argument (one of XYZ, XZY, YXZ, YZX, ZXY, ZYX) to influence which + Pauli operators appear most frequently in the Pauli strings. The default is 'ZXY', due to + the fact that the Z gate is usually the most natively supported gate. + + Args: + pauli_priority (str) : Priority with which Pauli operators are assigned. + + Raises: + ValueError: if pauli_priority is not one of XYZ, XZY, YXZ, YZX, ZXY, ZYX + """ + super().__init__() + self._pauli_priority = pauli_priority.upper() + if self._pauli_priority not in ("XYZ", "XZY", "YXZ", "YZX", "ZXY", "ZYX"): + raise ValueError("Pauli priority must be one of XYZ, XZY, YXZ, YZX, ZXY, ZYX") + + def pauli_table(self, register_length: int) -> list[tuple[PauliType, PauliType]]: + return self._pauli_table(self._pauli_priority, register_length)[1] + + def _pauli_string_length(self, register_length: int) -> int: + return self._pauli_table(self._pauli_priority, register_length)[0] + + @staticmethod + @lru_cache(maxsize=32) + def _pauli_table( + pauli_priority: str, + register_length: int + ) -> tuple[int, list[tuple[PauliType]]]: + tree_height = 0 + while 3 ** (tree_height + 1) <= register_length + 1: + tree_height += 1 + add_nodes = register_length + 1 - 3**tree_height + + pauli_x, pauli_y, pauli_z = tuple(pauli_priority) + pauli_list = [("", [], 1)] + qubit_index = 0 + for _ in range(tree_height): + new_pauli_list = [] + for paulis, qubits, coeff in pauli_list: + new_pauli_list.append((paulis + pauli_x, qubits + [qubit_index], coeff)) + new_pauli_list.append((paulis + pauli_y, qubits + [qubit_index], coeff)) + new_pauli_list.append((paulis + pauli_z, qubits + [qubit_index], coeff)) + qubit_index += 1 + pauli_list = new_pauli_list + while add_nodes > 0: + paulis, qubits, coeff = pauli_list.pop(0) + pauli_list.append((paulis + pauli_x, qubits + [qubit_index], coeff)) + pauli_list.append((paulis + pauli_y, qubits + [qubit_index], coeff)) + add_nodes -= 1 + if add_nodes > 0: + pauli_list.append((paulis + pauli_z, qubits + [qubit_index], coeff)) + add_nodes -= 1 + qubit_index += 1 + + num_qubits = qubit_index + pauli_list.pop() # only 2n of the 2n+1 operators are independent + pauli_ops = [(SparsePauliOp.from_sparse_list([pauli], num_qubits),) for pauli in pauli_list] + return num_qubits, pauli_ops + + def sparse_pauli_operators( + self, register_length: int + ) -> tuple[list[SparsePauliOp], list[SparsePauliOp]]: + times_creation_annihiliation_op = [] + + for paulis in self.pauli_table(register_length): + # For Majorana ops (self adjoint) pauli_table is a list of 1-element tuples + creation_op = SparsePauliOp(paulis[0], coeffs=[1]) + times_creation_annihiliation_op.append(creation_op) + + return (times_creation_annihiliation_op, times_creation_annihiliation_op) diff --git a/releasenotes/notes/add-ternary-tree-mapper-dad9b02ae4af54d2.yaml b/releasenotes/notes/add-ternary-tree-mapper-dad9b02ae4af54d2.yaml new file mode 100644 index 000000000..dbb130564 --- /dev/null +++ b/releasenotes/notes/add-ternary-tree-mapper-dad9b02ae4af54d2.yaml @@ -0,0 +1,8 @@ +--- +features: + - | + Adds a new mapper class, :class:`~qiskit_nature.second_q.mappers.TernaryTreeMapper` + along with a new parent class :class:`~qiskit_nature.second_q.mappers.MajoranaMapper` + implementing the Ternary Tree based mapping alogrithm + (cf. `arXiv:1910.10746 `_) that maps a `MajoranaOp` + to a `SparsePauliOp`. diff --git a/test/second_q/mappers/test_ternary_tree_mapper.py b/test/second_q/mappers/test_ternary_tree_mapper.py new file mode 100644 index 000000000..410931ddc --- /dev/null +++ b/test/second_q/mappers/test_ternary_tree_mapper.py @@ -0,0 +1,182 @@ +# This code is part of a Qiskit project. +# +# (C) Copyright IBM 2023. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +""" Test Ternary Tree Mapper """ + +import unittest +from test import QiskitNatureTestCase + +import numpy as np +from qiskit.circuit import Parameter +from qiskit.quantum_info import SparsePauliOp +from qiskit.algorithms.eigensolvers import NumPyEigensolver + +import qiskit_nature.optionals as _optionals +from qiskit_nature.second_q.drivers import PySCFDriver +from qiskit_nature.second_q.mappers import TernaryTreeMapper +from qiskit_nature.second_q.operators import MajoranaOp + + +class TestTernaryTreeMapper(QiskitNatureTestCase): + """Test Ternary Tree Mapper""" + + REF_H2 = SparsePauliOp.from_list( + [ + ("IIII", -0.81054798160031430), + ("ZIII", -0.22575349071287365), + ("IZII", +0.17218393211855787), + ("ZZII", +0.12091263243164174), + ("IIZI", -0.22575349071287362), + ("ZIZI", +0.17464343053355980), + ("IZZI", +0.16614543242281926), + ("IIIZ", +0.17218393211855818), + ("ZIIZ", +0.16614543242281926), + ("IZIZ", +0.16892753854646372), + ("IIZZ", +0.12091263243164174), + ("XXXX", +0.04523279999117751), + ("YYXX", +0.04523279999117751), + ("XXYY", +0.04523279999117751), + ("YYYY", +0.04523279999117751), + ] + ) + + @unittest.skipIf(not _optionals.HAS_PYSCF, "pyscf not available.") + def test_mapping(self): + """Test spectrum of H2 molecule.""" + driver = PySCFDriver() + driver_result = driver.run() + fermionic_op, _ = driver_result.second_q_ops() + majorana_op = MajoranaOp.from_fermionic_op(fermionic_op) + mapper = TernaryTreeMapper() + qubit_op = mapper.map(majorana_op) + result = NumPyEigensolver().compute_eigenvalues(qubit_op).eigenvalues + expected = NumPyEigensolver().compute_eigenvalues(TestTernaryTreeMapper.REF_H2).eigenvalues + self.assertTrue(np.isclose(result, expected)) + + def test_pauli_table(self): + """Test that Pauli table satisfies Majorana anticommutation relations.""" + for num_modes in range(1, 10): + pauli_tab = [p[0] for p in TernaryTreeMapper("XYZ").pauli_table(num_modes)] + self.assertEqual(len(pauli_tab), num_modes) + identity = SparsePauliOp("I" * pauli_tab[0].num_qubits) + for i, first in enumerate(pauli_tab): + anticommutator = (first.dot(first) + first.dot(first)).simplify() + self.assertEqual(anticommutator, 2 * identity) + for j in range(i): + second = pauli_tab[j] + anticommutator = (first.dot(second) + second.dot(first)).simplify() + self.assertEqual(anticommutator, 0 * identity) + + def test_mapping_for_single_op(self): + """Test for double register operator.""" + with self.subTest("test all ops for num_modes=4"): + num_modes = 4 + ops = ["_0", "_1", "_2", "_3"] + expected = ["IY", "IZ", "XX", "YX"] + for op, pauli_string in zip(ops, expected): + majorana_op = MajoranaOp({op: 1}, num_modes=num_modes) + expected_op = SparsePauliOp.from_list([(pauli_string, 1)]) + mapped = TernaryTreeMapper("XYZ").map(majorana_op) + self.assertEqualSparsePauliOp(mapped, expected_op) + + with self.subTest("test all ops for num_modes=8"): + num_modes = 8 + ops = ["_0", "_1", "_2", "_3", "_4", "_5", "_6", "_7"] + expected = ["IIXX", "IIYX", "IIZX", "IXIY", "IYIY", "IZIY", "XIIZ", "YIIZ"] + for op, pauli_string in zip(ops, expected): + majorana_op = MajoranaOp({op: 1}, num_modes=num_modes) + expected_op = SparsePauliOp.from_list([(pauli_string, 1)]) + mapped = TernaryTreeMapper("XYZ").map(majorana_op) + self.assertEqualSparsePauliOp(mapped, expected_op) + + with self.subTest("test parameters"): + a = Parameter("a") + op = MajoranaOp({"_0": a}, num_modes=2) + expected = SparsePauliOp.from_list([("X", a)], dtype=object) + qubit_op = TernaryTreeMapper("XYZ").map(op) + self.assertEqual(qubit_op, expected) + + with self.subTest("test empty operator"): + op = MajoranaOp({}, num_modes=2) + expected = SparsePauliOp.from_list([("I", 0)]) + qubit_op = TernaryTreeMapper("XYZ").map(op) + self.assertEqual(qubit_op, expected) + + with self.subTest("test constant operator"): + op = MajoranaOp({"": 2.2}, num_modes=2) + expected = SparsePauliOp.from_list([("I", 2.2)]) + qubit_op = TernaryTreeMapper("XYZ").map(op) + self.assertEqual(qubit_op, expected) + + def test_mapping_for_list_ops(self): + """Test for list of single register operator.""" + ops = [ + MajoranaOp({"_0": 1}, num_modes=2), + MajoranaOp({"_1": 1}, num_modes=2), + MajoranaOp({"_0 _1": 1}, num_modes=2), + ] + expected = [ + SparsePauliOp.from_list([("X", 1)]), + SparsePauliOp.from_list([("Y", 1)]), + SparsePauliOp.from_list([("Z", 1j)]), + ] + + mapped_ops = TernaryTreeMapper("XYZ").map(ops) + self.assertEqual(len(mapped_ops), len(expected)) + for mapped_op, expected_op in zip(mapped_ops, expected): + self.assertEqual(mapped_op, expected_op) + + def test_mapping_for_dict_ops(self): + """Test for dict of single register operator.""" + ops = { + "gamma0": MajoranaOp({"_0": 1}, num_modes=2), + "gamma1": MajoranaOp({"_1": 1}, num_modes=2), + "gamma0 gamma1": MajoranaOp({"_0 _1": 1}, num_modes=2), + } + expected = { + "gamma0": SparsePauliOp.from_list([("X", 1)]), + "gamma1": SparsePauliOp.from_list([("Y", 1)]), + "gamma0 gamma1": SparsePauliOp.from_list([("Z", 1j)]), + } + + mapped_ops = TernaryTreeMapper("XYZ").map(ops) + self.assertEqual(len(mapped_ops), len(expected)) + for k in mapped_ops.keys(): + self.assertEqual(mapped_ops[k], expected[k]) + + def test_mapping_overwrite_reg_len(self): + """Test overwriting the register length.""" + op = MajoranaOp({"_0 _1": 1}, num_modes=2) + expected = MajoranaOp({"_0 _1": 1}, num_modes=3) + mapper = TernaryTreeMapper("XYZ") + self.assertEqual(mapper.map(op, register_length=3), mapper.map(expected)) + + def test_mapping_pauli_priority(self): + """Test different settings for Pauli priority.""" + ops = [MajoranaOp({"_0": 1}, num_modes=2), MajoranaOp({"_1": 1}, num_modes=2)] + strings = "XYZ", "XZY", "YXZ", "YZX", "ZXY", "ZYX" + for string in strings: + with self.subTest(string): + mapped = TernaryTreeMapper(string).map(ops) + expected = [ + SparsePauliOp.from_list([(string[0], 1)]), + SparsePauliOp.from_list([(string[1], 1)]), + ] + self.assertEqual(mapped, expected) + + with self.subTest("invalid"): + with self.assertRaises(ValueError): + TernaryTreeMapper("ABC") + + +if __name__ == "__main__": + unittest.main() From 0f9416c9137970d87a0c45025dd3e18ec4de47fc Mon Sep 17 00:00:00 2001 From: grossardt <99898527+grossardt@users.noreply.github.com> Date: Sun, 28 Jan 2024 02:21:50 +0100 Subject: [PATCH 17/22] Lint/spellcheck --- .pylintdict | 9 +++++++++ qiskit_nature/second_q/mappers/mode_based_mapper.py | 2 +- qiskit_nature/second_q/mappers/ternary_tree_mapper.py | 7 +++---- .../notes/add-ternary-tree-mapper-dad9b02ae4af54d2.yaml | 2 +- 4 files changed, 14 insertions(+), 6 deletions(-) diff --git a/.pylintdict b/.pylintdict index 83419e034..54da641b8 100644 --- a/.pylintdict +++ b/.pylintdict @@ -280,6 +280,7 @@ jcf jcp jernigan ji +jiang jk jl jordan @@ -289,6 +290,7 @@ json jupyter jw kagome +kalev kanav ket kitaev @@ -359,6 +361,7 @@ moller molssi morse mpl +mruczkiewicz mul multi multigraph @@ -379,6 +382,7 @@ neq networkx neuropeptide neutron +neven nicholas nielsen nisq @@ -661,11 +665,16 @@ xcfun xdata xy xyz +xzy ydata yx +yxz yy +yzx zeitschrift zi zmatrix zsh +zxy +zyx zz diff --git a/qiskit_nature/second_q/mappers/mode_based_mapper.py b/qiskit_nature/second_q/mappers/mode_based_mapper.py index d25fd4d6b..2933f0fc9 100644 --- a/qiskit_nature/second_q/mappers/mode_based_mapper.py +++ b/qiskit_nature/second_q/mappers/mode_based_mapper.py @@ -127,7 +127,7 @@ def mode_based_mapping( for term in terms: char = term[0] position = int(term[1]) - if char in ("+", ""): # "" for MajoranaOp, creator = annihiliator + if char in ("+", ""): # "" for MajoranaOp, creator = annihilator ret_op = ret_op.compose(times_creation_op[position], front=True).simplify() elif char == "-": ret_op = ret_op.compose(times_annihilation_op[position], front=True).simplify() diff --git a/qiskit_nature/second_q/mappers/ternary_tree_mapper.py b/qiskit_nature/second_q/mappers/ternary_tree_mapper.py index 8c8bad671..c1c5d49d0 100644 --- a/qiskit_nature/second_q/mappers/ternary_tree_mapper.py +++ b/qiskit_nature/second_q/mappers/ternary_tree_mapper.py @@ -29,7 +29,7 @@ class TernaryTreeMapper(MajoranaMapper, ModeBasedMapper): preprint at `arXiv:1910.10746 `_. This is a mapper for :class:`~qiskit_nature.second_q.operators.MajoranaOp`. - For mappping :class:`~qiskit_nature.second_q.operators.FermionicOp` convert + For mapping :class:`~qiskit_nature.second_q.operators.FermionicOp` convert to a Majorana operator first: .. code-block:: @@ -49,7 +49,7 @@ def __init__(self, pauli_priority: str = "ZXY") -> TernaryTreeMapper: """ Use the Pauli priority argument (one of XYZ, XZY, YXZ, YZX, ZXY, ZYX) to influence which Pauli operators appear most frequently in the Pauli strings. The default is 'ZXY', due to - the fact that the Z gate is usually the most natively supported gate. + the fact that the Z gate is usually the most directly supported gate. Args: pauli_priority (str) : Priority with which Pauli operators are assigned. @@ -71,8 +71,7 @@ def _pauli_string_length(self, register_length: int) -> int: @staticmethod @lru_cache(maxsize=32) def _pauli_table( - pauli_priority: str, - register_length: int + pauli_priority: str, register_length: int ) -> tuple[int, list[tuple[PauliType]]]: tree_height = 0 while 3 ** (tree_height + 1) <= register_length + 1: diff --git a/releasenotes/notes/add-ternary-tree-mapper-dad9b02ae4af54d2.yaml b/releasenotes/notes/add-ternary-tree-mapper-dad9b02ae4af54d2.yaml index dbb130564..44e81bad6 100644 --- a/releasenotes/notes/add-ternary-tree-mapper-dad9b02ae4af54d2.yaml +++ b/releasenotes/notes/add-ternary-tree-mapper-dad9b02ae4af54d2.yaml @@ -3,6 +3,6 @@ features: - | Adds a new mapper class, :class:`~qiskit_nature.second_q.mappers.TernaryTreeMapper` along with a new parent class :class:`~qiskit_nature.second_q.mappers.MajoranaMapper` - implementing the Ternary Tree based mapping alogrithm + implementing the Ternary Tree based mapping algorithm (cf. `arXiv:1910.10746 `_) that maps a `MajoranaOp` to a `SparsePauliOp`. From 343da0e27226f233cca6f3906edc1ada74d7a9d5 Mon Sep 17 00:00:00 2001 From: grossardt <99898527+grossardt@users.noreply.github.com> Date: Sun, 28 Jan 2024 10:30:04 +0100 Subject: [PATCH 18/22] Copyright years fixed --- qiskit_nature/second_q/mappers/__init__.py | 2 +- qiskit_nature/second_q/mappers/bosonic_mapper.py | 2 +- qiskit_nature/second_q/mappers/majorana_mapper.py | 2 +- qiskit_nature/second_q/mappers/mode_based_mapper.py | 2 +- qiskit_nature/second_q/mappers/ternary_tree_mapper.py | 2 +- test/second_q/mappers/test_ternary_tree_mapper.py | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/qiskit_nature/second_q/mappers/__init__.py b/qiskit_nature/second_q/mappers/__init__.py index 055f9230f..4b02ed80d 100644 --- a/qiskit_nature/second_q/mappers/__init__.py +++ b/qiskit_nature/second_q/mappers/__init__.py @@ -1,6 +1,6 @@ # This code is part of a Qiskit project. # -# (C) Copyright IBM 2022, 2023. +# (C) Copyright IBM 2022, 2024. # # This code is licensed under the Apache License, Version 2.0. You may # obtain a copy of this license in the LICENSE.txt file in the root directory diff --git a/qiskit_nature/second_q/mappers/bosonic_mapper.py b/qiskit_nature/second_q/mappers/bosonic_mapper.py index 5f12fea0d..7165abb6b 100644 --- a/qiskit_nature/second_q/mappers/bosonic_mapper.py +++ b/qiskit_nature/second_q/mappers/bosonic_mapper.py @@ -1,6 +1,6 @@ # This code is part of a Qiskit project. # -# (C) Copyright IBM 2023. +# (C) Copyright IBM 2023, 2024. # # This code is licensed under the Apache License, Version 2.0. You may # obtain a copy of this license in the LICENSE.txt file in the root directory diff --git a/qiskit_nature/second_q/mappers/majorana_mapper.py b/qiskit_nature/second_q/mappers/majorana_mapper.py index 0f142878e..6a884682e 100644 --- a/qiskit_nature/second_q/mappers/majorana_mapper.py +++ b/qiskit_nature/second_q/mappers/majorana_mapper.py @@ -1,6 +1,6 @@ # This code is part of a Qiskit project. # -# (C) Copyright IBM 2023. +# (C) Copyright IBM 2024. # # This code is licensed under the Apache License, Version 2.0. You may # obtain a copy of this license in the LICENSE.txt file in the root directory diff --git a/qiskit_nature/second_q/mappers/mode_based_mapper.py b/qiskit_nature/second_q/mappers/mode_based_mapper.py index 2933f0fc9..8a2d3a648 100644 --- a/qiskit_nature/second_q/mappers/mode_based_mapper.py +++ b/qiskit_nature/second_q/mappers/mode_based_mapper.py @@ -1,6 +1,6 @@ # This code is part of a Qiskit project. # -# (C) Copyright IBM 2023. +# (C) Copyright IBM 2023, 2024. # # This code is licensed under the Apache License, Version 2.0. You may # obtain a copy of this license in the LICENSE.txt file in the root directory diff --git a/qiskit_nature/second_q/mappers/ternary_tree_mapper.py b/qiskit_nature/second_q/mappers/ternary_tree_mapper.py index c1c5d49d0..e508b3c2b 100644 --- a/qiskit_nature/second_q/mappers/ternary_tree_mapper.py +++ b/qiskit_nature/second_q/mappers/ternary_tree_mapper.py @@ -1,6 +1,6 @@ # This code is part of a Qiskit project. # -# (C) Copyright IBM 2023. +# (C) Copyright IBM 2024. # # This code is licensed under the Apache License, Version 2.0. You may # obtain a copy of this license in the LICENSE.txt file in the root directory diff --git a/test/second_q/mappers/test_ternary_tree_mapper.py b/test/second_q/mappers/test_ternary_tree_mapper.py index 410931ddc..cc9ce0906 100644 --- a/test/second_q/mappers/test_ternary_tree_mapper.py +++ b/test/second_q/mappers/test_ternary_tree_mapper.py @@ -1,6 +1,6 @@ # This code is part of a Qiskit project. # -# (C) Copyright IBM 2023. +# (C) Copyright IBM 2024. # # This code is licensed under the Apache License, Version 2.0. You may # obtain a copy of this license in the LICENSE.txt file in the root directory From 27448011831e91c02320b7b8ce9bdafdeb8e4f15 Mon Sep 17 00:00:00 2001 From: grossardt <99898527+grossardt@users.noreply.github.com> Date: Sun, 28 Jan 2024 10:54:07 +0100 Subject: [PATCH 19/22] copyright years (missed one) --- qiskit_nature/second_q/mappers/bosonic_linear_mapper.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qiskit_nature/second_q/mappers/bosonic_linear_mapper.py b/qiskit_nature/second_q/mappers/bosonic_linear_mapper.py index a06794d3d..8c8514513 100644 --- a/qiskit_nature/second_q/mappers/bosonic_linear_mapper.py +++ b/qiskit_nature/second_q/mappers/bosonic_linear_mapper.py @@ -1,6 +1,6 @@ # This code is part of a Qiskit project. # -# (C) Copyright IBM 2023. +# (C) Copyright IBM 2023, 2024. # # This code is licensed under the Apache License, Version 2.0. You may # obtain a copy of this license in the LICENSE.txt file in the root directory From eae1e2d0719c5ed71ef51d2d33a2d65df301c835 Mon Sep 17 00:00:00 2001 From: grossardt <99898527+grossardt@users.noreply.github.com> Date: Sun, 28 Jan 2024 13:57:58 +0100 Subject: [PATCH 20/22] Make pauli_table for TernaryTreeMapper compatible --- .../second_q/mappers/ternary_tree_mapper.py | 18 ++++++++++++++---- .../mappers/test_ternary_tree_mapper.py | 5 ++++- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/qiskit_nature/second_q/mappers/ternary_tree_mapper.py b/qiskit_nature/second_q/mappers/ternary_tree_mapper.py index e508b3c2b..ed8374e6e 100644 --- a/qiskit_nature/second_q/mappers/ternary_tree_mapper.py +++ b/qiskit_nature/second_q/mappers/ternary_tree_mapper.py @@ -45,7 +45,7 @@ class TernaryTreeMapper(MajoranaMapper, ModeBasedMapper): _pauli_priority: str = "ZXY" - def __init__(self, pauli_priority: str = "ZXY") -> TernaryTreeMapper: + def __init__(self, pauli_priority: str = "ZXY"): """ Use the Pauli priority argument (one of XYZ, XZY, YXZ, YZX, ZXY, ZYX) to influence which Pauli operators appear most frequently in the Pauli strings. The default is 'ZXY', due to @@ -63,7 +63,17 @@ def __init__(self, pauli_priority: str = "ZXY") -> TernaryTreeMapper: raise ValueError("Pauli priority must be one of XYZ, XZY, YXZ, YZX, ZXY, ZYX") def pauli_table(self, register_length: int) -> list[tuple[PauliType, PauliType]]: - return self._pauli_table(self._pauli_priority, register_length)[1] + """This method is implemented for ``TernaryTreeMapper`` only for compatibility. + ``TernaryTreeMapper.map`` only uses the ``sparse_pauli_operators`` method which + overrides the corresponding method in `ModeBasedMapper`. + """ + pauli_table = [] + for pauli in self._pauli_table(self._pauli_priority, register_length)[1]: + # creator/annihilator are constructed as (real +/- imaginary) / 2 + # for Majorana ops (self adjoint) we have imaginary = 0 and need + # to multiply the operators from _pauli_table by 2 to get the correct pre-factor + pauli_table.append((2 * pauli[0], SparsePauliOp([""]))) + return pauli_table def _pauli_string_length(self, register_length: int) -> int: return self._pauli_table(self._pauli_priority, register_length)[0] @@ -79,7 +89,7 @@ def _pauli_table( add_nodes = register_length + 1 - 3**tree_height pauli_x, pauli_y, pauli_z = tuple(pauli_priority) - pauli_list = [("", [], 1)] + pauli_list: list[tuple[str, list, int]] = [("", [], 1)] qubit_index = 0 for _ in range(tree_height): new_pauli_list = [] @@ -109,7 +119,7 @@ def sparse_pauli_operators( ) -> tuple[list[SparsePauliOp], list[SparsePauliOp]]: times_creation_annihiliation_op = [] - for paulis in self.pauli_table(register_length): + for paulis in self._pauli_table(self._pauli_priority, register_length)[1]: # For Majorana ops (self adjoint) pauli_table is a list of 1-element tuples creation_op = SparsePauliOp(paulis[0], coeffs=[1]) times_creation_annihiliation_op.append(creation_op) diff --git a/test/second_q/mappers/test_ternary_tree_mapper.py b/test/second_q/mappers/test_ternary_tree_mapper.py index cc9ce0906..ae0eb6932 100644 --- a/test/second_q/mappers/test_ternary_tree_mapper.py +++ b/test/second_q/mappers/test_ternary_tree_mapper.py @@ -65,7 +65,10 @@ def test_mapping(self): def test_pauli_table(self): """Test that Pauli table satisfies Majorana anticommutation relations.""" for num_modes in range(1, 10): - pauli_tab = [p[0] for p in TernaryTreeMapper("XYZ").pauli_table(num_modes)] + pauli_tab = [] + for pauli in TernaryTreeMapper("XYZ").pauli_table(num_modes): + self.assertEqualSparsePauliOp(pauli[1], SparsePauliOp([""])) + pauli_tab.append(pauli[0] / 2) self.assertEqual(len(pauli_tab), num_modes) identity = SparsePauliOp("I" * pauli_tab[0].num_qubits) for i, first in enumerate(pauli_tab): From 386a579f5ab42b3761d34c9d654962a6b88c8cf4 Mon Sep 17 00:00:00 2001 From: grossardt <99898527+grossardt@users.noreply.github.com> Date: Tue, 27 Feb 2024 00:47:59 +0100 Subject: [PATCH 21/22] fixes deprecation to pass tests --- qiskit_nature/second_q/operators/bosonic_op.py | 5 ++--- qiskit_nature/second_q/operators/fermionic_op.py | 5 ++--- qiskit_nature/second_q/operators/majorana_op.py | 5 ++--- qiskit_nature/second_q/operators/spin_op.py | 5 ++--- qiskit_nature/second_q/problems/properties_container.py | 5 ++--- test/second_q/mappers/test_ternary_tree_mapper.py | 2 +- 6 files changed, 11 insertions(+), 16 deletions(-) diff --git a/qiskit_nature/second_q/operators/bosonic_op.py b/qiskit_nature/second_q/operators/bosonic_op.py index 86e5ae43d..56ce46bc5 100644 --- a/qiskit_nature/second_q/operators/bosonic_op.py +++ b/qiskit_nature/second_q/operators/bosonic_op.py @@ -1,6 +1,6 @@ # This code is part of a Qiskit project. # -# (C) Copyright IBM 2023. +# (C) Copyright IBM 2023, 2024. # # This code is licensed under the Apache License, Version 2.0. You may # obtain a copy of this license in the LICENSE.txt file in the root directory @@ -214,8 +214,7 @@ def _validate_keys(self, keys: Collection[str]) -> None: for term in key.split(): index = int(term[2:]) if num_so is None: - if index > max_index: - max_index = index + max_index = max(max_index, index) elif index >= num_so: raise QiskitNatureError( f"The index, {index}, from the label, {key}, exceeds the number of spin " diff --git a/qiskit_nature/second_q/operators/fermionic_op.py b/qiskit_nature/second_q/operators/fermionic_op.py index fc50609de..3c846b547 100644 --- a/qiskit_nature/second_q/operators/fermionic_op.py +++ b/qiskit_nature/second_q/operators/fermionic_op.py @@ -1,6 +1,6 @@ # This code is part of a Qiskit project. # -# (C) Copyright IBM 2021, 2023. +# (C) Copyright IBM 2021, 2024. # # This code is licensed under the Apache License, Version 2.0. You may # obtain a copy of this license in the LICENSE.txt file in the root directory @@ -215,8 +215,7 @@ def _validate_keys(self, keys: Collection[str]) -> None: for term in key.split(): index = int(term[2:]) if num_so is None: - if index > max_index: - max_index = index + max_index = max(max_index, index) elif index >= num_so: raise QiskitNatureError( f"The index, {index}, from the label, {key}, exceeds the number of spin " diff --git a/qiskit_nature/second_q/operators/majorana_op.py b/qiskit_nature/second_q/operators/majorana_op.py index 9302e61b4..b6c67f884 100644 --- a/qiskit_nature/second_q/operators/majorana_op.py +++ b/qiskit_nature/second_q/operators/majorana_op.py @@ -1,6 +1,6 @@ # This code is part of a Qiskit project. # -# (C) Copyright IBM 2023. +# (C) Copyright IBM 2023, 2024. # # This code is licensed under the Apache License, Version 2.0. You may # obtain a copy of this license in the LICENSE.txt file in the root directory @@ -269,8 +269,7 @@ def _validate_keys(self, keys: Collection[str]) -> None: for term in key.split(): index = int(term[1:]) if num_modes is None: - if index > max_index: - max_index = index + max_index = max(max_index, index) elif index >= num_modes: raise QiskitNatureError( f"The index, {index}, from the label, {key}, exceeds the number of " diff --git a/qiskit_nature/second_q/operators/spin_op.py b/qiskit_nature/second_q/operators/spin_op.py index adc53dee0..5dd9eaebb 100644 --- a/qiskit_nature/second_q/operators/spin_op.py +++ b/qiskit_nature/second_q/operators/spin_op.py @@ -1,6 +1,6 @@ # This code is part of a Qiskit project. # -# (C) Copyright IBM 2021, 2023. +# (C) Copyright IBM 2021, 2024. # # This code is licensed under the Apache License, Version 2.0. You may # obtain a copy of this license in the LICENSE.txt file in the root directory @@ -275,8 +275,7 @@ def _validate_keys(self, keys: Collection[str]) -> None: # sub_terms[0] is the base, sub_terms[1] is the (optional) exponent index = int(sub_terms[0][2:]) if num_s is None: - if index > max_index: - max_index = index + max_index = max(max_index, index) elif index >= num_s: raise QiskitNatureError( f"The index, {index}, from the label, {key}, exceeds the number of " diff --git a/qiskit_nature/second_q/problems/properties_container.py b/qiskit_nature/second_q/problems/properties_container.py index 3fa04794b..4a751d25b 100644 --- a/qiskit_nature/second_q/problems/properties_container.py +++ b/qiskit_nature/second_q/problems/properties_container.py @@ -1,6 +1,6 @@ # This code is part of a Qiskit project. # -# (C) Copyright IBM 2022, 2023. +# (C) Copyright IBM 2022, 2024. # # This code is licensed under the Apache License, Version 2.0. You may # obtain a copy of this license in the LICENSE.txt file in the root directory @@ -80,8 +80,7 @@ def __len__(self) -> int: return len(self._properties) def __iter__(self) -> Generator[SparseLabelOpsFactory, None, None]: - for prop in self._properties.values(): - yield prop + yield from self._properties.values() def _getter(self, _type: type) -> SparseLabelOpsFactory | None: """An internal utility method to handle the attribute getter implementation. diff --git a/test/second_q/mappers/test_ternary_tree_mapper.py b/test/second_q/mappers/test_ternary_tree_mapper.py index ae0eb6932..a10f9d043 100644 --- a/test/second_q/mappers/test_ternary_tree_mapper.py +++ b/test/second_q/mappers/test_ternary_tree_mapper.py @@ -18,7 +18,7 @@ import numpy as np from qiskit.circuit import Parameter from qiskit.quantum_info import SparsePauliOp -from qiskit.algorithms.eigensolvers import NumPyEigensolver +from qiskit_algorithms import NumPyEigensolver import qiskit_nature.optionals as _optionals from qiskit_nature.second_q.drivers import PySCFDriver From b6d9a26d87259875be43958edc30cc618ccf2c2f Mon Sep 17 00:00:00 2001 From: grossardt <99898527+grossardt@users.noreply.github.com> Date: Sat, 20 Apr 2024 00:21:42 +0200 Subject: [PATCH 22/22] Update mode_based_mapper.py --- qiskit_nature/second_q/mappers/mode_based_mapper.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/qiskit_nature/second_q/mappers/mode_based_mapper.py b/qiskit_nature/second_q/mappers/mode_based_mapper.py index e5d76db80..79593a0a8 100644 --- a/qiskit_nature/second_q/mappers/mode_based_mapper.py +++ b/qiskit_nature/second_q/mappers/mode_based_mapper.py @@ -61,7 +61,7 @@ def sparse_pauli_operators( # pylint: disable=unused-argument """Generates the :class:`.SparsePauliOp` terms. - This uses :meth:`.QubitMapper.pauli_table` to construct a list of operators used to + This uses :meth:`.pauli_table` to construct a list of operators used to translate the second-quantization symbols into qubit operators. Args: @@ -113,22 +113,21 @@ def mode_based_mapping( register_length = second_q_op.register_length times_creation_op, times_annihilation_op = self.sparse_pauli_operators(register_length) + mapped_string_length = times_creation_op[0].num_qubits # make sure ret_op_list is not empty by including a zero op - ret_op_list = [SparsePauliOp("I" * register_length, coeffs=[0])] + ret_op_list = [SparsePauliOp("I" * mapped_string_length, coeffs=[0])] for terms, coeff in second_q_op.terms(): # 1. Initialize an operator list with the identity scaled by the `coeff` - ret_op = SparsePauliOp("I" * register_length, coeffs=np.array([coeff])) + ret_op = SparsePauliOp("I" * mapped_string_length, coeffs=np.array([coeff])) # Go through the label and replace the fermion operators by their qubit-equivalent, then # save the respective Pauli string in the pauli_str list. for term in terms: char = term[0] - if char == "": - break position = int(term[1]) - if char == "+": + if char in ("+", ""): # "" for MajoranaOp, creator = annihilator ret_op = ret_op.compose(times_creation_op[position], front=True).simplify() elif char == "-": ret_op = ret_op.compose(times_annihilation_op[position], front=True).simplify()