Hamiltonian simulation with quantum computation

Emilio Peláez
Quantum Untangled
Published in
7 min readAug 21, 2022
Molecular Dynamics Simulation of Ion Propagation through a Protein Ion Channel. Image by Ljubomir T. Citkusev, Richard Brower and Charles DeLisi available here.

One of the most sought-after applications of quantum computing is the simulation of physical processes, e.g., simulating molecules for drug development and many-body problems. These simulations generally involve taking a Hamiltonian operator H and a time t which is used to evolve an arbitrary state from |ψ⟩ to e^[iHt]|ψ⟩. Creating a circuit that applies the operator e^[iHt] is not immediately obvious, but we can start off with a two-qubit example.

Simulating σ_i exponentials

Suppose we want to simulate e^[i σ_z⊗σ_z t]. The circuit to do this is known to be [1]:

R_z on second qubit surrounded by CX gates with first qubit as control and second qubit as target in both sides

where the argument to the rotation gate R_z is −2t.

Showing that this circuit is equivalent to e^[i σ_z⊗σ_z t] is not too hard. Remember that we have the Euler-like decomposition of the operator as e^[i σ_z⊗σ_z t]=cos⁡(t)I+i sin⁡(t) σ_z⊗σ_z (which works since we have that σ_z^2=I). Thus, we have to show that the circuit above simplifies to this same expression. Doing this is a bit long but not hard, as shown below:

Proof that the circuit is equivalent to the expected operator

This can easily be extended to any number of qubits, i.e., e^[i σ_z⊗σ_z⊗⋯⊗σ_z t] [3]. Now, what happens if we have a Pauli matrix that is not σ_z in the exponent? Well, just apply a self-inverse gate U before and after the chain of CXs such that σ_k=Uσ_zU, where k∈{x,y}. This gate would be the Hadamard gate for σ_x and

Self-inverse gate for σ_y

for σ_y. And if its the identity, you can just leave that qubit unaffected!

Simulating general exponentials

But this just tells us how to simulate Pauli strings, and we may not be given the Hamiltonian H on this form. However, it is a fact that for any matrix A with its dimension being a power of 2 (which would be the case here since we are working with qubits), there exists some decomposition into a Pauli string of the following form [2]:

where

Thus, knowing how to simulate Pauli strings, we can basically simulate any Hamiltonian H. Using the Pauli string of a Hamiltonian, we can construct the Trotter decomposition for which we can create a circuit based of the simple circuit we saw above. The Trotter decomposition looks as follows:

where P_k is a tensor product of Pauli matrices, c_k the coefficient corresponding to each P_k, t the time introduced at the beginning, and N a number called the Trotter number. The bigger N is, the more accurate our simulation is. The specifics of Trotter decomposition are out of the scope of this article, but a good start is chapter 4.1 of [3].

Qiskit implementation

With this background in place, we can start implementing the simulation in Qiskit. First we are going to define a simple function that creates the circuit scheme introduced at the beginsning for operators of the form e^[i σ_z⊗σ_z⊗⋯⊗σ_z t].

from qiskit import QuantumCircuit
import numpy as np

def sim_z(t, qc, qubits):
"""
Add gates to simulate a Pauli Z string exponential

Parameters:
t: float
Time parameter to simulate for
qc: QuantumCircuit
Circuit to append gates to
qubits: array
Array indicating qubits indeces (in order) to
append the gates to
"""
for i in range(len(qubits) - 1):
qc.cx(qubits[i], qubits[i + 1])
qc.rz(-2 * t, qubits[-1])
for i in range(len(qubits) - 1, 0, -1):
qc.cx(qubits[i - 1], qubits[i])
qc = QuantumCircuit(3)
sim_z(np.pi, qc, [0, 1, 2])
qc.draw()

Which outputs:

q_0: ──■─────────────────────────■──
┌─┴─┐ ┌─┴─┐
q_1: ┤ X ├──■───────────────■──┤ X ├
└───┘┌─┴─┐┌─────────┐┌─┴─┐└───┘
q_2: ─────┤ X ├┤ Rz(-2π) ├┤ X ├─────
└───┘└─────────┘└───┘

Now, we can expand this so we can simulate any Pauli string.

def sim_pauli(arr, t, qc, qubits):
"""
Append gates to simulate any Pauli string

Parameters:
arr: array
Array encoding the Pauli string
t: float
Time parameter to simulate for
qc: QuantumCircuit
Circuit to append gates to
qubits: array
Array indicating qubits indeces (in order) to
append the gates to
"""
new_arr = []
new_qub = []
for idx in range(len(arr)):
if arr[idx] != 'I':
new_arr.append(arr[idx])
new_qub.append(qubits[idx])

h_y = 1 / np.sqrt(2) * np.array([[1, -1j], [1j, -1]])
for i in range(len(new_arr)):
if new_arr[i] == 'X':
qc.h(new_qub[i])
elif new_arr[i] == 'Y':
qc.unitary(h_y, [new_qub[i]], r'$H_y$')

sim_z(t, qc, new_qub)

for i in range(len(new_arr)):
if new_arr[i] == 'X':
qc.h(new_qub[i])
elif new_arr[i] == 'Y':
qc.unitary(h_y, [new_qub[i]], r'$H_y$')

And finally, we write a small function that takes in a Hamiltonian H as its Trotter decomposition and simulates it in a given quantum circuit.

def sim_ham(hamiltonian, t, qc, qubits, trotter=1):
"""
Simulates Hamiltonian given as Pauli string

Parameters:
hamiltonian: dict
Dictionary encoding the hamiltonian with each
Pauli product as a key with the coefficient as value
t: float
Time parameter to simulate for
qc: QuantumCircuit
Circuit to append gates to
qubits: array
Array indicating qubits indeces (in order) to
append the gates to

"""
temp = QuantumCircuit(len(qubits))
delta_t = t / trotter
for pauli in hamiltonian:
sim_pauli(pauli, hamiltonian[pauli] * delta_t, temp, range(len(qubits)))

for i in range(trotter):
qc.compose(temp, qubits, inplace=True)

Analyzing this function, we can see that it basically resembles this formula that we introduced in the background:

First, we see that delta_t corresponds to t/N. Then, for each Pauli term in the Pauli string representing the Hamiltonian (the first loop corresponds to the product ∏_k, since consecutive gates correspond to matrix multiplication), we call our function sim_pauli that adds the corresponding gates, using time = hamiltonian[pauli] * delta_t, which corresponds to c_k t/N in the formula. Finally, we simply append the resulting circuit N times in the second loop, which resembles the exponentiation.

Comparing to exact calculation

To wrap everything up, we can see how our simulation circuit compares to directly computing e^[iHt]|ψ⟩. Here, we test this with the Hamiltonian H=2XZY+5ZXX+2YXZ, time 1/(2∗π) and N=50, which gives us high accuracy.

from qiskit import Aer, execute
from sympy import Matrix

qc = QuantumCircuit(3)
sim_ham({"XZY": 2, "ZXX": 5, "YXZ": 2}, 1 / (2 * np.pi), qc, [0, 1, 2], trotter=50)
qc = qc.reverse_bits()

backend = Aer.get_backend('statevector_simulator')
result = execute(qc, backend).result()
vec = result.get_statevector()
vec = vec / vec[0] # global phase
qvec = vec / np.linalg.norm(vec) # normalize
Matrix(np.round(qvec, 5))
Output of the circuit simulation

Now that we have our result with a quantum circuit, we can directly compute the expected result and compare.

import scipy

# Start with |0> state
start = np.zeros(2 ** 3)
start[0] = 1

# Get the matrix corresponding to some Pauli product
# This function can be optimized, but it is left as it
# is for clarity purposes
def get_pauli(string):
init = string[0]
string = string[1:]
if init == 'X':
out = np.array([[0, 1], [1, 0]])
elif init == 'Z':
out = np.array([[1, 0], [0, -1]])
elif init == 'Y':
out = np.array([[0, -1j], [1j, 0]])
else:
out = np.eye(2)

for p in string:
if p == 'X':
out = np.kron(out, np.array([[0, 1], [1, 0]]))
elif p == 'Z':
out = np.kron(out, np.array([[1, 0], [0, -1]]))
elif p == 'Y':
out = np.kron(out, np.array([[0, -1j], [1j, 0]]))
else:
out = np.kron(out, np.eye(2))

return out
# Hamiltonian is calculated from the Pauli decomposition
decomp = {"XZY": 2, "ZXX": 5, "YXZ": 2}
H = np.zeros((2 ** 3, 2 ** 3))
H = H.astype('complex128')
for pauli in decomp:
H += decomp[pauli] * get_pauli(pauli)

# Hamiltonian is exponentiated and we multiply the starting
# vector by it to get our result
simul = scipy.linalg.expm(1j * H * (1 / (2 * np.pi)))
vec = simul @ start
vec = vec / vec[0] # global phase
cvec = vec / np.linalg.norm(vec) # normalize
Matrix(np.round(cvec, 5))
Output from numerical calculation

By simply comparing the two vectors, we can say that our simulation was pretty accurate! As a more mathematically rigorous comparison, we can use state fidelity, which also shows us that our simulation is pretty good!

from qiskit.quantum_info import state_fidelity

state_fidelity(qvec, cvec)

Which gives:

0.9999834345555809

There is a Jupyter notebook version of this article available here. If you enjoyed this post, remember to follow Quantum Untangled for more similar content!

References

  • [1] An approach for Hamiltonian simulation by Davit Khachatryan on Quantum Computing StackExchange.
  • [2] Can arbitrary matrices be decomposed using the Pauli basis? by Danylo Y on Quantum Computing StackExchange.
  • [3] Simulation of electronic structure Hamiltonians using quantum computers by James D. Whitfield ,Jacob Biamonte and Alán Aspuru-Guzik.

--

--