Fermion to Qubit Mapping#

In this tutorial we will see how to map a fermionic Hamiltonian to a qubit Hamiltonian using PauliArray. We will also make a comparison with similar tools provided in Qiskit and show that PauliArray accomplishes the same task much faster.

Fermionic Hamiltonian with Qiskit#

As a starting point, we will use Qiskit to generate the fermionic Hamiltonian (FermioncOp) for the \(\text{N}_2\) molecule.

from qiskit_nature.second_q.drivers import PySCFDriver

mol_info = {
    "atom": "N 0 0 -0.545;N 0 0 0.545;",
    "basis": "sto3g",
    "charge": 0,
    "spin": 0,
}

driver = PySCFDriver(**mol_info)
problem = driver.run()
hamiltonian = problem.hamiltonian
second_q_hamiltonian = problem.hamiltonian.second_q_op()

Jordan Wigner Mapping with Qiskit#

Carrying out the mapping with Qiskit is pretty straight forward. The result is a SparsePauliOp. We can measure the time it takes to complete the process.

import time

from qiskit_nature.second_q.mappers import JordanWignerMapper

t0 = time.time()

mapper = JordanWignerMapper()
qk_jw_qubit_hamiltonian = mapper.map(second_q_hamiltonian)

print(f"Qiskit : {time.time() - t0:.3f} sec")
print(f"Number of qubits : {qk_jw_qubit_hamiltonian.num_qubits}")
print(f"Number of Pauli strings : {len(qk_jw_qubit_hamiltonian)}")
Qiskit : 2.049 sec
Number of qubits : 20
Number of Pauli strings : 2951

As a not so rigorous benchmark, it takes about 2.1 sec to an Apple M2 to complete the mapping for the \(\text{N}_2\) molecule involving 20 qubits and 2951 Pauli strings.

Jordan Wigner Mapping with PauliArray#

The process is pretty similar using PauliArray except we need to convert the FermioncOp into arguments compatible with the FermionMapping. We also need to specify the number of qubits to initialize the JordanWigner mapping. The result is a Operator.

from pauliarray.conversion.qiskit import extract_fermionic_op
from pauliarray.mapping.fermion import JordanWigner

num_spin_orbitals = second_q_hamiltonian.num_spin_orbitals

t0 = time.time()

one_body_tuple, two_body_tuple = extract_fermionic_op(second_q_hamiltonian)

mapping = JordanWigner(num_spin_orbitals)
pa_jw_qubit_hamiltonien = mapping.assemble_qubit_hamiltonian_from_sparses(one_body_tuple, two_body_tuple)

print(f"PauliArray : {time.time() - t0:.3f} sec")
print(f"Number of qubits : {pa_jw_qubit_hamiltonien.num_qubits}")
print(f"Number of Pauli strings : {pa_jw_qubit_hamiltonien.num_terms}")
PauliArray : 0.124 sec
Number of qubits : 20
Number of Pauli strings : 2951

Again, the not so rigorous benchmarking shows that PauliArray completes the mapping in less than 0.15 sec, which is more than 10 times faster than Qiskit.

We can check that both result are the same by converting the Operator into a SparsePauliOp.

from pauliarray.conversion.qiskit import operator_to_sparse_pauli

print(operator_to_sparse_pauli(pa_jw_qubit_hamiltonien).sort() == qk_jw_qubit_hamiltonian.sort())
True

General (Random) Mapping with PauliArray#

PauliArray allows for constructing mapping for \(n\) states by providing an invertible binary component \(n\times n\) matrix. To show this, we will consider a smaller molecule \(\text{LiH}\).

mol_info = {
    "atom": "Li 0 0 0;H 0 0 1.6;",
    "basis": "sto3g",
    "charge": 0,
    "spin": 0,
}

driver = PySCFDriver(**mol_info)
problem = driver.run()
hamiltonian = problem.hamiltonian
second_q_hamiltonian = problem.hamiltonian.second_q_op()

num_spin_orbitals = second_q_hamiltonian.num_spin_orbitals

one_body_tuple, two_body_tuple = extract_fermionic_op(second_q_hamiltonian)

print(f"{num_spin_orbitals=}")
num_spin_orbitals=12

Let’s construct such a matrix randomly. Noteworthy, such a matrix with 1 on the diagonal, an upper triangle filled with 0, and a random lower triangle is guaranteed to be invertible.

mapping_matrix = np.eye(num_spin_orbitals, dtype=int) + np.tril(
    np.random.randint(0, 2, (num_spin_orbitals, num_spin_orbitals)), k=-1
)

print(mapping_matrix)
[[1 0 0 0 0 0 0 0 0 0 0 0]
 [1 1 0 0 0 0 0 0 0 0 0 0]
 [1 1 1 0 0 0 0 0 0 0 0 0]
 [0 0 0 1 0 0 0 0 0 0 0 0]
 [0 0 1 1 1 0 0 0 0 0 0 0]
 [0 1 0 0 0 1 0 0 0 0 0 0]
 [0 1 1 0 1 1 1 0 0 0 0 0]
 [1 1 1 0 1 0 0 1 0 0 0 0]
 [0 0 0 0 0 0 0 0 1 0 0 0]
 [0 0 1 1 1 1 0 1 0 1 0 0]
 [1 0 0 1 1 0 1 0 0 0 1 0]
 [0 0 1 0 1 0 1 0 1 1 1 1]]

To initialize the mapping, we only need to provide this matrix to FermionMapping. The mapping is then used in the same way as before to construct a qubit Hamiltonian.

from pauliarray.mapping.fermion import FermionMapping

mapping = FermionMapping(mapping_matrix)
pa_rd_qubit_hamiltonien = mapping.assemble_qubit_hamiltonian_from_sparses(one_body_tuple, two_body_tuple)

Finally, to confirm that such a mapping is valid we can compare the qubit Hamiltonian it produces with the one we get from Jordan-Wigner mapping. These two Hamiltonians are expressing the same operator but in different basis. Therefore, their eigenvalues should be equals.

Let’s check that this is true. This may take a while.

jw_mapping = JordanWigner(num_spin_orbitals)
pa_jw_qubit_hamiltonien = rd_mapping.assemble_qubit_hamiltonian_from_sparses(one_body_tuple, two_body_tuple)

eigvals_jw = np.linalg.eigvals(pa_jw_qubit_hamiltonien.to_matrix())
eigvals_rd = np.linalg.eigvals(pa_rd_qubit_hamiltonien.to_matrix())

print(np.all(np.sort(eigvals_jw) == np.sort(eigvals_rd)))
True