Symmetry

The use of symmetry to simplify computational problems in quantum chemistry is well-established. A symmetry of an operator \(\hat{O}\) can be described by an operator \(\hat{S}\) on the same Hilbert space, where the two operators \(\hat{O}\) and \(\hat{S}\) commute. InQuanto contains special purpose classes to represent symmetry operators in both the fermionic (inquanto.operators.SymmetryOperatorFermionic) and qubit (inquanto.operators.SymmetryOperatorPauli) spaces. These subclass their respective operator classes, and as such may otherwise be constructed and used in the same way. Both classes provide additional functionality related to symmetry, above the normal inquanto.operators.FermionOperator and inquanto.operators.QubitOperator classes. Class method is_symmetry_of() can verify (by commutation) that a given symmetry operator is indeed a symmetry of a given operator within the same space. Class method symmetry_sector() yields the symmetry sector that a provided state is in - i.e. the expectation value of the symmetry operator with the provided state.

from inquanto.operators import SymmetryOperatorPauli
from inquanto.operators import QubitOperator
from inquanto.states import QubitState

symmetry = SymmetryOperatorPauli("Z0 Z1 Z2 Z3")
operator = QubitOperator("X0 X1 X2 X3")
state = QubitState([1,1,0,0])

print("Is it a symmetry? ", symmetry.is_symmetry_of(operator))
print("Symmetry sector with state: ", symmetry.symmetry_sector(state))
Is it a symmetry?  True
Symmetry sector with state:  1.0

Warning

Calculating a sector is performed by determining the expectation value of the state with the symmetry operator. For typical purposes (for instance, calculating the sector of a Hartree-Fock state with Z2 symmetry operators) this will be both efficient and produce predictable results. However, validity checking is not performed and the use of symmetry-inconsistent states may lead to inconsistent results, long execution times, or both.

For computational purposes, it is common to express fermionic symmetry operators as products of operators, rather than linear combinations. For example, the fermionic parity operator \((-1)^{\sum_i{\hat{N}_i}}\) may be expressed as a product \(\prod_i{1 - 2\hat{N}_i}\). The product form can be efficiently stored, whereas expanding out the product into a single linear combination may invoke computational difficulty. It is particularly useful when converting to a qubit representation, where individual multiplicands may be converted to single Pauli strings, thus avoiding any exponential growth in the number of terms.

In order to allow for symmetry operators in this form to be used in InQuanto, a inquanto.operators.SymmetryOperatorFermionicFactorised class is provided. These are a subclass of FermionOperatorList, and provide similar is_symmetry_of() and symmetry_sector() functionality to the main symmetry classes. Conversion is also possible with the to_symmetry_operator_fermionic() method. In all cases, care should again be taken to ensure excessive computational difficulty is not incurred.

Finding symmetries

InQuanto is capable of automatically generating symmetry operators on both fermionic and qubit spaces; however, the method of generation is different in each case. For fermionic Hilbert spaces, it is common to have some prior knowledge regarding the physical point group, electron number and spin symmetries. This information is typically provided by a precursor classical calculation and is associated with the FermionSpace class. InQuanto uses this information to generate the Z2 symmetry operators corresponding to the Z2 point group symmetries (using orbital irreducible representation information), the electron number parity and spin parity symmetries. This can be performed using the symmetry_operators_z2() method:

from inquanto.spaces import FermionSpace
point_group_label = "D2h"
irrep_labels = ["Ag", "Ag", "B1u", "B1u"]
fermion_space = FermionSpace(4, point_group_label, irrep_labels)
symmetry_operators_fermion = fermion_space.symmetry_operators_z2()
for x in symmetry_operators_fermion:
    print(x)
(1.0, ), (-2.0, F3^ F3 ), (-2.0, F2^ F2 ), (4.0, F2^ F2  F3^ F3 )
(1.0, ), (-2.0, F3^ F3 ), (-2.0, F2^ F2 ), (4.0, F2^ F2  F3^ F3 ), (-2.0, F1^ F1 ), (4.0, F1^ F1  F3^ F3 ), (4.0, F1^ F1  F2^ F2 ), (-8.0, F1^ F1  F2^ F2  F3^ F3 ), (-2.0, F0^ F0 ), (4.0, F0^ F0  F3^ F3 ), (4.0, F0^ F0  F2^ F2 ), (-8.0, F0^ F0  F2^ F2  F3^ F3 ), (4.0, F0^ F0  F1^ F1 ), (-8.0, F0^ F0  F1^ F1  F3^ F3 ), (-8.0, F0^ F0  F1^ F1  F2^ F2 ), (16.0, F0^ F0  F1^ F1  F2^ F2  F3^ F3 )
(1.0, ), (-2.0, F2^ F2 ), (-2.0, F0^ F0 ), (4.0, F0^ F0  F2^ F2 )

FermionSpace objects without point group symmetry information will generate symmetry operators corresponding to electron number parity and spin parity only.

Warning

Symmetry operators generated in this way may exponentially blow up in the number of terms. For advanced usage, the parameter return_factorized can be set to True to return operators as a inquanto.operators.SymmetryOperatorFermionicFactorised, as discussed above.

Conversely, QubitSpace objects in InQuanto do not store any information with regards to the physical symmetries of the system. Here, Z2 symmetries can be determined by providing an operator which preserves the relevant symmetries – typically the Hamiltonian. Symmetries are found by finding a set of independent generators for operators that commute with the provided operator. [53]

from inquanto.spaces import QubitSpace
from inquanto.express import load_h5
h2_sto3g = load_h5("h2_sto3g.h5", as_tuple=True)
h2_hamiltonian = h2_sto3g.hamiltonian_operator.to_FermionOperator().qubit_encode()
qubit_space = QubitSpace(4)
symmetry_operators_qubit = qubit_space.symmetry_operators_z2(h2_hamiltonian)
for x in symmetry_operators_qubit:
    print(x)
(1.0, Z0 Z1 I2 I3)
(1.0, Z0 I1 Z2 I3)
(1.0, Z0 I1 I2 Z3)

Point Group Symmetry

In quantum chemistry, it is common to explicitly consider molecular point group symmetries. While the symmetry_operators_z2() method of a FermionSpace forms a convenient way to generate Z2 symmetries (including Z2 point group symmetries), InQuanto contains tools for the explicit analysis of the molecular point group. The spatial symmetries of molecules and their molecular orbitals can be manipulated by using the inquanto.symmetry.PointGroup class. For example, a list of supported point groups can be obtained:

from inquanto.symmetry import PointGroup
print(PointGroup.supported_groups())
['C1', 'C2', 'C2h', 'C2v', 'C3v', 'Ci', 'Cs', 'D2', 'D2h', 'D3h', 'Oh', 'Td']

If the point group of interest is supported, the corresponding character table can be printed in a human-readable format:

from inquanto.symmetry import PointGroup
pg = PointGroup("C2v")
pg.print_character_table()
       C2v         E    C2 (z)  σ_v (xz)  σ_v (yz)
        A1         1         1         1         1
        A2         1         1        -1        -1
        B1         1        -1         1        -1
        B2         1        -1        -1         1

Components of representations expressible by the group can also be evaluated:

from inquanto.symmetry import PointGroup
pg = PointGroup("C2v")
print("[1, -1, -1, 1] components:", pg.compute_representation_components([1, -1, -1, 1]))
[1, -1, -1, 1] components: [(0, 'A1'), (0, 'A2'), (0, 'B1'), (1, 'B2')]

Similarly, the results of direct products of irreps can be obtained, both as a list of characters and as a decomposition into irreducible components.

from inquanto.symmetry import  PointGroup
pg = PointGroup("D2h")
components, direct_product = pg.irrep_direct_product(["Ag", "B1u"])
print("Ag, B1u direct product:", direct_product)
print("Ag, B1u direct product components:", components)
Ag, B1u direct product: [ 1  1 -1 -1 -1 -1  1  1]
Ag, B1u direct product components: [(0, 'Ag'), (0, 'B1g'), (0, 'B2g'), (0, 'B3g'), (0, 'Au'), (1, 'B1u'), (0, 'B2u'), (0, 'B3u')]

Z2 Tapering

Z2 qubit tapering [53] is a method of reducing the number of qubits needed to simulate a given fermionic Hamiltonian. Each independent Z2 symmetry divides the full \(2^N\) dimensional qubit state space into two \(2^{N-1}\) dimensional sectors - one where the expectation value of the symmetry operator is \(1\), and one where it is \(-1\). As the symmetry operator commutes with the Hamiltonian, each eigenstate of the Hamiltonian must have support exclusively on states within one of these sectors. By transforming the Hamiltonian such that the symmetry operators are mapped to single qubit operators (note that this is a Clifford operation and as such can be performed classically efficiently), the state of \(N_s\) qubits is fixed and can be inferred from the symmetry sector that the desired eigenstate is in. In ground state problems, this symmetry sector is known a priori – it will be the symmetry sector of the Hartree-Fock state, assuming that the true eigenstate has nonzero overlap with the Hartree-Fock state. As the state of these \(N_s\) qubits is fixed and known, there is no need to include them in the quantum calculation.

Z2 qubit tapering is implemented in InQuanto by the inquanto.symmetry.TapererZ2 class. To taper an operator, symmetry operators and the symmetry sectors of a known reference state must be provided. Symmetry operators may be obtained from either the fermionic or qubit space; however, in the former case the operators must be mapped to qubit operators using the same fermion-to-qubit encoding scheme as the qubit operator. Once the TapererZ2 object is instantiated for a given set of symmetry operators and sectors, it may be used to find tapered operators and states as below. The object may be reused, if tapering multiple operators with the same symmetries is required.

from inquanto.symmetry import TapererZ2
hf_state = QubitState([1,1,0,0])
symmetry_sectors = [x.symmetry_sector(hf_state) for x in symmetry_operators_qubit]

taperer = TapererZ2(symmetry_operators_qubit, symmetry_sectors)
tapered_hamiltonian = taperer.tapered_operator(h2_hamiltonian)
print(tapered_hamiltonian)
(-0.29264467227722657, I0), (0.8248612119271037, Z0), (0.17966867956301552, X0)

Note that if using a tapered Hamiltonian in a variational algorithm, the ansatz state must also be tapered. This is performed using additional functionality of the ansatz classes.

from inquanto.ansatzes import FermionSpaceAnsatzUCCSD
from inquanto.states import FermionState
reference_state = FermionState([1,1,0,0])
ansatz = FermionSpaceAnsatzUCCSD(
fermion_space,
reference_state,
taperer=taperer,
tapering_exponent_check_behaviour="discard",
)

Tip

For this to work, ansatz excitations must preserve symmetry. By default, FermionSpaceAnsatzUCCSD will check for symmetry violating excitations and throw an error if any are found. This behavior can be changed using the tapering_exponent_check_behaviour parameter, which is described in the API reference documentation for FermionSpaceStateExp, the base class of FermionSpaceAnsatzUCCSD.

Tip

Note that in the example above, taperer and tapering_exponent_check_behaviour are passed as **kwargs to FermionSpaceAnsatzUCCSD as it doesn’t have them as positional parameters. These are forwarded to its parent FermionSpaceStateExp class which handles the tapering.