Source code for pauliarray.mapping.fermion

import itertools
from typing import List, Tuple, Union

import numpy as np
from numpy.typing import NDArray

import pauliarray.pauli.operator as op
import pauliarray.pauli.operator_array_type_1 as opa
import pauliarray.pauli.pauli_array as pa
import pauliarray.pauli.weighted_pauli_array as wpa
from pauliarray.binary import bit_operations as bitops


[docs] class FermionMapping(object): """ Base class to represent a Fermion-to-qubit mapping. Attributes: mapping_matrix ("np.ndarray[np.bool]"): A boolean numpy array representing the mapping matrix. name (str): A name identifier for the mapping (default is "mapping"). _mapping_matrix_inv ("np.ndarray[np.bool]", optional): The inverse of the mapping matrix, initially None and computed on demand. _heavyside_matrix ("np.ndarray[np.bool]", optional): The Heavyside matrix, initially None and computed on demand. _parity_matrix ("np.ndarray[np.bool]", optional): The parity matrix, initially None and computed on demand. """
[docs] def __init__(self, mapping_matrix: "np.ndarray[np.bool]", name: str = "mapping"): """ Constructs all the necessary attributes for the FermionMapping object. Args: mapping_matrix : "np.ndarray[np.bool]" A boolean numpy array representing the mapping matrix. name : str, optional A name identifier for the mapping (default is "mapping"). """ self.mapping_matrix = mapping_matrix self.name = name self._mapping_matrix_inv = None self._heavyside_matrix = None self._parity_matrix = None
@property def num_qubits(self) -> int: """ Returns the number of qubits based on the shape of the mapping matrix. Returns: int: The number of qubits. """ return self.mapping_matrix.shape[0] @property def mapping_matrix_inv(self): """ Returns the inverse of the mapping matrix. Computes it if not already computed. Returns: "np.ndarray[np.bool]": The inverse of the mapping matrix. """ if self._mapping_matrix_inv is None: self._mapping_matrix_inv = bitops.inv(self.mapping_matrix) return self._mapping_matrix_inv @property def heavyside_matrix(self) -> "np.ndarray[np.bool]": """ Returns the Heavyside matrix. Computes it if not already computed. Returns: "np.ndarray[np.bool]": The Heavyside matrix. """ if self._heavyside_matrix is None: self._heavyside_matrix = np.tri(self.num_qubits, k=-1, dtype=np.bool_) return self._heavyside_matrix @property def parity_matrix(self) -> "np.ndarray[np.bool]": """ Returns the parity matrix. Computes it if not already computed. Returns: "np.ndarray[np.bool]": The parity matrix. """ if self._parity_matrix is None: self._parity_matrix = np.tri(self.num_qubits, dtype=np.bool_) return self._parity_matrix
[docs] def majoranas(self) -> Tuple[pa.PauliArray, pa.PauliArray]: r""" In a fermion-to-qubit mapping, each creation/annihilation operator is a sum of two majorana operators, .. math:: 0.5 * (P_\text{real} + P_\text{imag}) each being a Pauli string. This methods construct these majorana operators. Returns: PauliArray: The Pauli strings for :math:`P_\text{real}` PauliArray: The Pauli strings for :math:`P_\text{imag}` """ mapping_matrix = self.mapping_matrix mapping_matrix_inv = self.mapping_matrix_inv heavyside_matrix = self.heavyside_matrix parity_matrix = self.parity_matrix real_z_strings = bitops.matmul(heavyside_matrix, mapping_matrix_inv) imag_z_strings = bitops.matmul(parity_matrix, mapping_matrix_inv) real_x_strings = imag_x_strings = mapping_matrix.transpose() real_majoranas = pa.PauliArray(real_z_strings, real_x_strings) imag_majoranas = pa.PauliArray(imag_z_strings, imag_x_strings) return real_majoranas, imag_majoranas
[docs] def assemble_creation_annihilation_operators(self) -> Tuple[opa.OperatorArrayType1, opa.OperatorArrayType1]: """ Constructs the creation and annihilation operators for all available states and returns them as OperatorArrays. Returns: OperatorArrayType1: The creation operators OperatorArrayType1: The annihilation operators """ real_majoranas, imag_majoranas = self.majoranas() real_operators = opa.OperatorArrayType1.from_pauli_array(real_majoranas) imag_operators = opa.OperatorArrayType1.from_pauli_array(imag_majoranas) annihilation_operators = 0.5 * real_operators.add_operator_array_type_1(1j * imag_operators) creation_operators = annihilation_operators.adjoint() return creation_operators, annihilation_operators
[docs] def occupation_operators(self) -> opa.OperatorArrayType1: """ Constructs the occupation operators for all available states and returns them as OperatorArray. Returns: opa.OperatorArrayType1: An Operator array with all the occupation operators. """ mapping_matrix_inv = self.mapping_matrix_inv identity_paulis = pa.PauliArray( np.zeros((self.num_qubits, self.num_qubits), dtype=np.bool_), np.zeros((self.num_qubits, self.num_qubits), dtype=np.bool_), ) z_paulis = pa.PauliArray(mapping_matrix_inv, np.zeros((self.num_qubits, self.num_qubits), dtype=np.bool_)) identity_operators = opa.OperatorArrayType1.from_pauli_array(identity_paulis) z_operators = opa.OperatorArrayType1.from_pauli_array(z_paulis) occupation_operators = 0.5 * identity_operators.add_operator_array_type_1(-1 * z_operators) return occupation_operators
[docs] def assemble_qubit_hamiltonian_from_arrays( self, one_body: "np.ndarray[np.complex]", two_body: "np.ndarray[np.complex]" ) -> op.Operator: """ Assemble the whole qubit Hamiltonian as an Operator using fermionic integrals given as arrays. Args: one_body ("np.ndarray[np.complex]"): The one-body fermionic integrals as a 2d array two_body ("np.ndarray[np.complex]"): The two-body fermionic integrals as a 4d array (in physicist order) Returns: Operator: The qubit Hamiltonia """ one_body_operator = self.one_body_operator_from_array(one_body) two_body_operator = self.two_body_operator_from_array(two_body) return (one_body_operator + two_body_operator).combine_repeated_terms().remove_small_weights()
[docs] def assemble_qubit_hamiltonian_from_sparses(self, one_body_tuple: Tuple, two_body_tuple: Tuple) -> op.Operator: """ Assemble the whole qubit Hamiltonian as an Operator using fermionic integrals given in sparse representations. Args: one_body_tuple (Tuple): Contains the argument for the `one_body_operator_from_sparse` method. two_body_tuple (Tuple): Contains the argument for the `two_body_operator_from_sparse` method. Returns: op.Operator: The qubit Hamiltonian """ one_body_operator = self.one_body_operator_from_sparse(*one_body_tuple) two_body_operator = self.two_body_operator_from_sparse(*two_body_tuple) return (one_body_operator + two_body_operator).combine_repeated_terms().remove_small_weights()
[docs] def one_body_operator_from_sparse( self, locations: List["np.ndarray[np.int]"], values: "np.ndarray[np.complex]", signs: Union[List[int], "np.ndarray[np.int]", List["np.ndarray[np.int]"]] = (1, -1), ) -> op.Operator: """ Assemble a one body fermionic operator as an Operator using fermionic integrals given in sparse representations. Args: locations (List["np.ndarray[np.int]"]): Pairs of orbital indices values ("np.ndarray[np.complex]"): Values of the integral for the pairs of orbitals signs (List[int] or "np.ndarray[np.int]" or List["np.ndarray[np.int]"]): Values +1 or -1 determining if the operators are creation or annihilation. Can be a list of two signs, or two arrays of sign. Defaults to [1,-1]. Returns: op.Operator: The qubit operator """ num_terms = len(values) assert len(locations) == 2 for index in range(2): assert len(locations[index]) == num_terms assert len(signs) == 2 signs = list(signs) for index in range(2): if isinstance(signs[index], np.ndarray): assert len(signs[index]) == num_terms else: signs[index] = signs[index] * np.ones(num_terms) _is, _js = locations isigns, jsigns = signs flip_ij_operators = self._flip_operators(_is, isigns * self._flip_factors(_is, _js)) flip_j_operators = self._flip_operators(_js, jsigns) update_operators = self._update_operators(_is, _js) all_operators = ( update_operators.compose_operator_array_type_1(flip_ij_operators) .compose_operator_array_type_1(flip_j_operators) .mul_weights(values) ) one_body_operator = all_operators.sum() return one_body_operator
[docs] def two_body_operator_from_sparse( self, locations: List["np.ndarray[np.int]"], values: "np.ndarray[np.complex]", signs: Union[List[int], "np.ndarray[np.int]", List["np.ndarray[np.int]"]] = (1, 1, -1, -1), ) -> op.Operator: """ Assemble a two-body fermionic operator as an Operator using fermionic integrals given in sparse representations. Args: locations (List["np.ndarray[np.int]"]): Quartet of orbital indices values ("np.ndarray[np.complex]"): Values of the integral for the quartets of orbitals signs (List[int] or "np.ndarray[np.int]" or List["np.ndarray[np.int]"]): Values +1 or -1 determining if the operators are creation or annihilation. Can be a list of two signs, or two arrays of sign. Defaults to [+1,+1,-1,-1]. Returns: op.Operator: The qubit operator """ num_terms = len(values) assert len(locations) == 4 for index in range(4): assert len(locations[index]) == num_terms assert len(signs) == 4 signs = list(signs) for index in range(4): if isinstance(signs[index], np.ndarray): assert len(signs[index]) == num_terms else: signs[index] = signs[index] * np.ones(num_terms) _is, _js, _ks, _ls = locations isigns, jsigns, ksigns, lsigns = signs flip_ijkl_operators = self._flip_operators(_is, isigns * self._flip_factors(_is, _js, _ks, _ls)) flip_jkl_operators = self._flip_operators(_js, jsigns * self._flip_factors(_js, _ks, _ls)) flip_kl_operators = self._flip_operators(_ks, ksigns * self._flip_factors(_ks, _ls)) flip_l_operators = self._flip_operators(_ls, lsigns) update_operators = self._update_operators(_is, _js, _ks, _ls) all_operators = ( update_operators.compose_operator_array_type_1(flip_ijkl_operators) .compose_operator_array_type_1(flip_jkl_operators) .compose_operator_array_type_1(flip_kl_operators) .compose_operator_array_type_1(flip_l_operators) ).mul_weights(values) two_body_operator = all_operators.sum() return two_body_operator
[docs] def one_body_operator_from_array(self, one_body: "np.ndarray[np.complex]") -> op.Operator: """ Converts a one-body array to an operator object. Args: one_body ("np.ndarray[np.complex]"): A complex numpy array representing the one-body operator. Returns: op.Operator: The corresponding operator object. Raises: AssertionError. If the input array is not 2-dimensional or if its dimensions do not match the number of qubits. """ assert one_body.ndim == 2 assert all([s == self.num_qubits for s in one_body.shape]) non_zero_coef = ~np.isclose(one_body, 0) locations = np.where(non_zero_coef) values = one_body[locations[0], locations[1]] return self.one_body_operator_from_sparse(locations, values)
[docs] def two_body_operator_from_array(self, two_body: "np.ndarray[np.complex]") -> op.Operator: """ Converts a two-body array to an operator object. Parameters: two_body ("np.ndarray[np.complex]"): A complex numpy array representing the two-body operator. Returns: op.Operator: The corresponding operator object. Raises: AssertionError. If the input array's dimensions do not match the number of qubits or if it does not satisfy the required symmetries. """ assert all([s == self.num_qubits for s in two_body.shape]) assert np.all(np.isclose(two_body, np.einsum("ijkl->ikjl", two_body))) assert np.all(np.isclose(two_body, np.einsum("ijkl->ljki", two_body))) assert np.all(np.isclose(two_body, np.einsum("ijkl->jilk", two_body))) non_zero_coef = ~np.isclose(two_body, 0) locations = np.where(non_zero_coef) values = two_body[locations[0], locations[1], locations[2], locations[3]] return self.two_body_operator_from_sparse(locations, values)
[docs] def assemble_one_body_operator_array(self) -> opa.OperatorArrayType1: """ Assembles an array of one-body operators. Returns: opa.OperatorArrayType1: An operator array of one-body operators. """ creation_operators, annihilation_operators = self.assemble_creation_annihilation_operators() return creation_operators[:, None].compose_operator_array_type_1(annihilation_operators[None, :])
[docs] def assemble_two_body_operator_array(self) -> opa.OperatorArrayType1: """ Assembles an array of two-body operators. Returns: opa.OperatorArrayType1: An operator array of two-body operators. """ creation_operators, annihilation_operators = self.assemble_creation_annihilation_operators() double_annihilation = annihilation_operators[:, None].compose_operator_array_type_1( annihilation_operators[None, :] ) double_creaation = creation_operators[:, None].compose_operator_array_type_1(creation_operators[None, :]) tmp = double_creaation[:, :, None, None].compose_operator_array_type_1(double_annihilation[None, None, :, :]) return tmp
[docs] def _flip_operators(self, i_orbitals: "np.ndarray[np.int]", factors: NDArray[np.float64]) -> opa.OperatorArrayType1: r""" Constructs an OperatorArray with the :math:`\mu^\text{th}` flip operators acting on the orbitals :math:`i_\mu` .. math:: \hat{F}_\mu = \frac{1}{2}(1 + f_\mu \hat{Z}_q^{[\mathsf{M}^{-1}]_{i_\mu q}]}) where :math:`f_\mu` is a factor (+1 or -1) associted with the creation and annihilation operators. Args: i_orbitals (NDArray[int]): The indices of the orbital i the operator is acting on factors (NDArray[float]): The factors (+1 or -1) defining if a creation or an annihilation operator is applied. Returns: OperatorArrayType1: The array of flip operators. """ z_strings = self.mapping_matrix_inv[i_orbitals, :] z_operators = opa.OperatorArrayType1.from_pauli_array( pa.PauliArray(z_strings, np.zeros(z_strings.shape, dtype=np.bool_)) ) return z_operators.mul_weights(0.5 * factors).add_scalar(0.5)
[docs] def _update_operators( self, i_orbitals: "np.ndarray[np.int]", j_orbitals: "np.ndarray[np.int]", *args: Tuple["np.ndarray[np.int]"] ) -> opa.OperatorArrayType1: """ Updates the operators based on the given orbitals, using the Heaviside matrix, the inverse mapping matrix, and the transposed mapping matrix. The function processes the input orbitals to generate updated Z and X strings, used to create new Pauli operators. Args: i_orbitals ("np.ndarray[np.int]"): The first set of orbitals. j_orbitals ("np.ndarray[np.int]"): The second set of orbitals. *args (Tuple["np.ndarray[np.int]"]): Additional sets of orbitals. Returns: opa.OperatorArrayType1: The updated operators as an OperatorArrayType1 object. """ heavy_map_inv = bitops.matmul(self.heavyside_matrix, self.mapping_matrix_inv).astype(np.int8) mapping_matrix_tra = self.mapping_matrix.transpose().astype(np.int8) xs_orbitals = ( i_orbitals, j_orbitals, ) + args update_z_strings = 0 update_x_strings = 0 for x_orbitals in xs_orbitals: update_z_strings += heavy_map_inv[x_orbitals, :] update_x_strings += mapping_matrix_tra[x_orbitals, :] update_z_strings = np.mod(update_z_strings, 2).astype(np.bool_) update_x_strings = np.mod(update_x_strings, 2).astype(np.bool_) update_factors = self._update_factors(*xs_orbitals) update_paulis = pa.PauliArray(update_z_strings, update_x_strings) update_operators = opa.OperatorArrayType1.from_pauli_array(update_paulis).mul_weights(update_factors) return update_operators
[docs] def _update_operators_2( self, i_orbitals: "np.ndarray[np.int]", j_orbitals: "np.ndarray[np.int]" ) -> opa.OperatorArrayType1: r""" Constructs an OperatorArray with the :math:`\mu^\text{th}` main operators acting on the orbitals :math:`i_\mu` and :math:`j_\mu` in a one-body fermionic operator .. math:: \hat{U}^{(2)}_{\mu} = (-1)^{\theta_{i_\mu j_ \mu}} \hat{X}_q^{M_{qi_\mu} + M_{qj_\mu}}. Args: i_orbitals (NDArray[int]): The indices of the orbital i the operator is acting on. j_orbitals (NDArray[int]): The indices of the orbital j the operator is acting on. Returns: OperatorArrayType1: The array of operators. """ heavy_map_inv = bitops.matmul(self.heavyside_matrix, self.mapping_matrix_inv).astype(np.int8) mapping_matrix_tra = self.mapping_matrix.transpose().astype(np.int8) update_z_strings = bitops.add(heavy_map_inv[i_orbitals, :], heavy_map_inv[j_orbitals, :]) update_x_strings = bitops.add(mapping_matrix_tra[i_orbitals, :], mapping_matrix_tra[j_orbitals, :]) update_factors = self._update_factors(i_orbitals, j_orbitals) update_paulis = pa.PauliArray(update_z_strings, update_x_strings) update_operators = opa.OperatorArrayType1.from_pauli_array(update_paulis).mul_weights(update_factors) return update_operators
[docs] def _update_operators_4( self, i_orbitals: "np.ndarray[np.int]", j_orbitals: "np.ndarray[np.int]", k_orbitals: "np.ndarray[np.int]", l_orbitals: "np.ndarray[np.int]", ) -> opa.OperatorArrayType1: r""" Constructs an OperatorArray with the :math:`\mu^\text{th}` main operators acting on the orbitals :math:`i_\mu`, :math:`j_\mu`, :math:`k_\mu` and :math:`l_\mu` in a two-body fermionic operator. .. math:: (-1)^{\theta_{ij} + \theta_{i_\mu k_\mu} + \theta_{i_\mu l_\mu} + \theta_{j_\mu k_\mu} + \theta_{j_\mu l_\mu} + \theta_{k_\mu l_\mu}} \hat{X}_q^{M_{qi_\mu} + M_{qj_\mu} + M_{qk_\mu} + M_{ql_\mu}} \hat{Z}_q^{(\theta_{i_\mu p} + \theta_{j_\mu p} + \theta_{k_\mu p} + \theta_{l_\mu p})[\mathsf{M}^{-1}]_{pq}} Args: i_orbitals (NDArray[int]): The indices of the orbital i the operator is acting on. j_orbitals (NDArray[int]): The indices of the orbital j the operator is acting on. k_orbitals (NDArray[int]): The indices of the orbital k the operator is acting on. l_orbitals (NDArray[int]): The indices of the orbital l the operator is acting on. Returns: OperatorArrayType1: The array of operators. """ heavy_map_inv = bitops.matmul(self.heavyside_matrix, self.mapping_matrix_inv).astype(np.int8) mapping_matrix_tra = self.mapping_matrix.transpose().astype(np.int8) update_z_strings = np.mod( heavy_map_inv[i_orbitals, :] + heavy_map_inv[j_orbitals, :] + heavy_map_inv[k_orbitals, :] + heavy_map_inv[l_orbitals, :], 2, ).astype(np.bool_) update_x_strings = np.mod( mapping_matrix_tra[i_orbitals, :] + mapping_matrix_tra[j_orbitals, :] + mapping_matrix_tra[k_orbitals, :] + mapping_matrix_tra[l_orbitals, :], 2, ).astype(np.bool_) update_factors = self._update_factors(i_orbitals, j_orbitals, k_orbitals, l_orbitals) update_paulis = pa.PauliArray(update_z_strings, update_x_strings) update_operators = opa.OperatorArrayType1.from_pauli_array(update_paulis).mul_weights(update_factors) return update_operators
[docs] @staticmethod def _flip_factors( i_orbitals: "np.ndarray[np.int]", j_orbitals: "np.ndarray[np.int]", *args: Tuple["np.ndarray[np.int]"] ) -> NDArray[np.float64]: r""" Computes flip factors of the type .. math:: f_\mu = (-1)^{\delta_{i_\mu j_\mu} + \delta_{i_\mu k_\mu} + \ldots}. Args: i_orbitals ("np.ndarray[np.int]"): The indices of the orbital i the operator is acting on. j_orbitals ("np.ndarray[np.int]"): The indices of the orbital i the operator is acting on. etc... Returns: NDArray[float]: The factors. """ xs_orbitals = (j_orbitals,) + args factors = 1 for x_orbitals in xs_orbitals: factors *= np.choose(i_orbitals == x_orbitals, [1, -1]) return factors
[docs] def _update_factors( self, i_orbitals: "np.ndarray[np.int]", j_orbitals: "np.ndarray[np.int]", *args: Tuple["np.ndarray[np.int]"] ) -> NDArray[np.float64]: r""" Computes update factors of the type .. math:: f_\mu = (-1)^{\theta_{i_\mu j_\mu} - \theta_{j_\mu i_\mu} + \ldots}. Args: i_orbitals ("np.ndarray[np.int]"): The indices of the orbital i the operator is acting on. j_orbitals ("np.ndarray[np.int]"): The indices of the orbital i the operator is acting on. etc... Returns: NDArray[float]: The factors. """ xs_orbitals = ( i_orbitals, j_orbitals, ) + args heavyside_matrix = self.heavyside_matrix.astype(np.int8) phases = 0 for x_orbitals, y_orbitals in itertools.combinations(xs_orbitals, 2): phases += heavyside_matrix[x_orbitals, y_orbitals] - heavyside_matrix[y_orbitals, x_orbitals] # TODO : check which one is faster # factors = np.choose(np.mod(phase, 4), [1, 1j, -1, -1j]) # factors = np.choose(np.bitwise_and(phase - phase, 3), [1, 1j, -1, -1j]) # factors = np.choose(phase - phase, [1, 1j, -1, -1j] * 2, mode="wrap") factors = np.choose(phases, [1, 1j, -1, -1j] * 2, mode="wrap") return factors
[docs] class JordanWigner(FermionMapping):
[docs] def __init__(self, num_qubits: int): """ Initializes the Jordan-Wigner mapping for a given number of qubits. Maps fermionic operators to qubit operators. Args: num_qubits (int): The number of qubits to be used in the mapping. """ FermionMapping.__init__(self, np.eye(num_qubits, dtype=np.bool_), "jordan-wigner")
@property def __name__(self): """ Gets the name of the mapping. Returns: str: The name of the mapping, "JordanWigner". """ return "JordanWigner"
[docs] class Parity(FermionMapping):
[docs] def __init__(self, num_qubits: int): """ Initializes the Parity mapping for a given number of qubits. Maps fermionic operators to qubit operators. Args: num_qubits (int): The number of qubits to be used in the mapping. """ FermionMapping.__init__(self, np.tri(num_qubits, dtype=np.bool_), "parity")
@property def __name__(self): """ Gets the name of the mapping. Returns: str: The name of the mapping, "Parity". """ return "Parity"
[docs] class BravyiKitaev(FermionMapping):
[docs] def __init__(self, num_qubits: int): """ Initialize the Bravyi-Kitaev mapping for a given number of qubits. Maps fermionic operators to qubit operators. Args: num_qubits (int): The number of qubits to be used in the mapping. """ FermionMapping.__init__(self, self._build_bravyi_kitaev_mapping_matrix(num_qubits), "bravyi-kitaev")
[docs] def _build_bravyi_kitaev_mapping_matrix(self, num_qubits: int) -> "np.ndarray[np.bool]": """ Constructs the Bravyi-Kitaev mapping matrix for the given number of qubits. Args: num_qubits (int): The number of qubits. Returns: "np.ndarray[np.bool]": The Bravyi-Kitaev mapping matrix. """ mapping_matrix = np.eye(num_qubits, dtype=np.bool_) for i in range(1, num_qubits + 1, 2): if np.log2(i + 1) % 1 == 0: mapping_matrix[i, : i + 1] = True else: mapping_matrix[i, 2 ** int(np.log2(i + 1)) : i + 1] = True return mapping_matrix
@property def __name__(self): """ Gets the name of the mapping. Returns: str: The name of the mapping, "BravyiKitaev". """ return "BravyiKitaev"