FQE vs OpenFermion vs Cirq: Quadratic Hamiltonian Evolution

View on QuantumAI Run in Google Colab View source on GitHub Download notebook

This notebook demonstrates how evolve a state under a quadratic Hamiltonian with FQE, Cirq, and OpenFermion.

We first generate a random quadratic hamiltonian using OpenFermion. Then we set up a full \(2^{n} \times 2^{n}\) operator and a computational basis state corresponding to a Slater determinant with the zero and one indexed alpha spin-orbitals occupied. We then compare the full space time evolutions to the LU-decomposition evolution implemented in FQE. Lastly, we using the optimal_givens_decomposition to implement the quadratic Hamiltonian time evolution in Cirq. The 1-RDMs are compared for correctness. We purposefully picked a high spin state so the spin-summed 1-RDM from FQE is equivalent to the spinless 1-RDM.

try:
    import fqe
except ImportError:
    !pip install fqe --quiet
from typing import Union
from itertools import product
import fqe
from fqe.hamiltonians.restricted_hamiltonian import RestrictedHamiltonian

import numpy as np
import cirq

import openfermion as of
from openfermion.testing.testing_utils import random_quadratic_hamiltonian
from openfermion.ops import QuadraticHamiltonian
from openfermion.linalg.givens_rotations import givens_decomposition_square
from openfermion.circuits.primitives import optimal_givens_decomposition

from scipy.sparse import csc_matrix
from scipy.linalg import expm
from scipy.special import binom
import scipy as sp
# set up the number of orbs and a random quadratic Hamiltonian
norbs = 4
ikappa = random_quadratic_hamiltonian(norbs, conserves_particle_number=True, real=True, expand_spin=False, seed=2)
ikappa_mat = of.get_sparse_operator(of.get_fermion_operator(ikappa), n_qubits=norbs)
# set up initial full Hilbert space wavefunction
wfn = np.zeros((2**(norbs), 1), dtype=np.complex128)
wfn[int("".join([str(x) for x in [1] * (norbs//2) + [0] * (norbs//2)]), 2), 0] = 1  # alpha1-alpha2 Hartree-Fock state
# full space unitary
time = np.random.random()
hamiltonian_evolution = expm(-1j * ikappa_mat.todense() * time)
# build a FQE operator corresponding to the Hamiltonian evolution
fqe_ham = RestrictedHamiltonian((ikappa.n_body_tensors[1, 0],))
assert fqe_ham.quadratic()
# initialize the FQE wavefunction [n-elec, sz, norbs]
fqe_wfn = fqe.Wavefunction([[norbs // 2, norbs//2, norbs]])
hf_wf = np.zeros((int(binom(norbs, norbs // 2)), 1), dtype=np.complex128)
hf_wf[0, 0] = 1  # right most bit is zero orbital.
fqe_wfn.set_wfn(strategy='from_data',
                raw_data={(norbs//2, norbs//2): hf_wf})
fqe_wfn.print_wfn()
Sector N = 2 : S_z = 2
a'0011'b'0000' (1+0j)

Aside

The FQE and cirq/OpenFermion have reverse ordering of the occupation number vectors. Give a bitstring representing occupations 01001 one commonly wants to determine the index of the orbitals used in the 2-orbital Slater determinant. In OpenFermion the orbitals used are 1 and 4. In FQE the orbitals used are 0 and 3. The difference is that OpenFermion starts indexing from the left most bit whereas FQE starts indexing from the right most bit.

# initialize wavefunction [n-elec, sz, norbs]
fqe_wfn = fqe.Wavefunction([[norbs // 2, norbs//2, norbs]])
hf_wf = np.zeros((int(binom(norbs, norbs // 2)), 1), dtype=np.complex128)
hf_wf[0, 0] = 1  # right most bit is zero orbital. 2-alpha electron HF is 0011.  This is the first element of our particle conserved Hilbert space
fqe_wfn.set_wfn(strategy='from_data',
                raw_data={(norbs//2, norbs//2): hf_wf})
fqe_wfn.print_wfn()

# OpenFermion indexes from the left as zero.  The RHF state is 1100
wfn[int("".join([str(x) for x in [1] * (norbs//2) + [0] * (norbs//2)]), 2), 0] = 1

print("\nHF-Vector-OpenFermion ", bin(int("".join([str(x) for x in [1] * (norbs//2) + [0] * (norbs//2)]), 2)))
Sector N = 2 : S_z = 2
a'0011'b'0000' (1+0j)

HF-Vector-OpenFermion  0b1100
fqe_wfn.print_wfn()  # before
fqe_wfn = fqe_wfn.time_evolve(time, fqe_ham)
fqe_wfn.print_wfn()  # after
Sector N = 2 : S_z = 2
a'0011'b'0000' (1+0j)
Sector N = 2 : S_z = 2
a'0011'b'0000' (0.7640756559841199+0.20777158789940361j)
a'0101'b'0000' (-0.17313823898087422+0.2638365952059404j)
a'1001'b'0000' (0.40785856832720757+0.06983556964686519j)
a'0110'b'0000' (-0.10456038050286236-0.10035394746174761j)
a'1010'b'0000' (-0.1978764858966419+0.19803152903956048j)
a'1100'b'0000' (0.008829529249272671-0.05247669185209847j)
final_wfn = hamiltonian_evolution @ wfn  # Full space time-evolution

Compare 1-RDMs

It is easy to compare the 1-RDMs of the wavefunctions to confirm they are representing the same state. We will compute the 1-RDM for the wfn and fqe_wfn and compare to the opdm computed from fqe.to_cirq.

def get_opdm(wfn: Union[sp.sparse.coo_matrix, sp.sparse.csr_matrix,
                        sp.sparse.csc_matrix], nso: int) -> np.ndarray:
    """
    A slow way to get 1-RDMs from density matrices or states
    """
    if isinstance(wfn, (sp.sparse.coo_matrix, sp.sparse.csc_matrix)):
        wfn = wfn.tocsr()
        wfn = wfn.toarray()
    opdm = np.zeros((nso, nso), dtype=wfn.dtype)
    a_ops = [
        of.get_sparse_operator(of.jordan_wigner(of.FermionOperator(((p, 0)))),
                               n_qubits=nso)
        for p in range(nso)
    ]
    for p, q in product(range(nso), repeat=2):
        operator = a_ops[p].conj().T @ a_ops[q]
        if wfn.shape[0] == wfn.shape[1]:  # we are working with a density matrix
            val = np.trace(wfn @ operator)
        else:
            val = wfn.conj().transpose() @ operator @ wfn
        # print((p, q, r, s), val.toarray()[0, 0])
        if isinstance(val, (float, complex, int)):
            opdm[p, q] = val
        else:
            opdm[p, q] = val[0, 0]

    return opdm
fqe_opdm = fqe_wfn.rdm('i^ j') # get the FQE-opdm

# get the exactly evolved opdm
initial_opdm = np.diag([1] * (norbs//2) + [0] * (norbs//2))
u = expm(1j * ikappa.n_body_tensors[1, 0] * time)
final_opdm = u @ initial_opdm @ u.conj().T
print(final_opdm)
print()

# contract the wavefunction opdm
test_opdm = get_opdm(final_wfn, norbs)
print(test_opdm)

assert np.allclose(test_opdm.real, final_opdm.real)
assert np.allclose(test_opdm.imag, final_opdm.imag)
assert np.allclose(final_opdm, fqe_opdm)

cirq_wfn = fqe.to_cirq(fqe_wfn).reshape((-1, 1))

cfqe_opdm = get_opdm(cirq_wfn, 2 * norbs)
print()
print(cfqe_opdm[::2, ::2])
assert np.allclose(final_opdm, cfqe_opdm[::2, ::2])

# cirq evolution
qubits = cirq.LineQubit.range(norbs)
prep = cirq.Moment([cirq.X.on(qubits[0]), cirq.X.on(qubits[1])])
rotation = cirq.Circuit(optimal_givens_decomposition(qubits, u.conj().copy()))
circuit = prep + rotation

final_state = circuit.final_state_vector().reshape((-1, 1))
cirq_opdm = get_opdm(final_state, norbs)
print()
print(cirq_opdm)

assert np.allclose(final_opdm, cirq_opdm)
[[ 0.89779286+0.j         -0.07524962+0.13954963j  0.1006792 +0.03293365j
   0.12542128-0.19918035j]
 [-0.07524962-0.13954963j  0.72635602+0.j         -0.08961216+0.2461997j
   0.32180163-0.03775482j]
 [ 0.1006792 -0.03293365j -0.08961216-0.2461997j   0.12342215+0.j
  -0.05137394-0.16026316j]
 [ 0.12542128+0.19918035j  0.32180163+0.03775482j -0.05137394+0.16026316j
   0.25242897+0.j        ]]

[[ 0.89779286+0.j         -0.07524962+0.13954963j  0.1006792 +0.03293365j
   0.12542128-0.19918035j]
 [-0.07524962-0.13954963j  0.72635602+0.j         -0.08961216+0.2461997j
   0.32180163-0.03775482j]
 [ 0.1006792 -0.03293365j -0.08961216-0.2461997j   0.12342215+0.j
  -0.05137394-0.16026316j]
 [ 0.12542128+0.19918035j  0.32180163+0.03775482j -0.05137394+0.16026316j
   0.25242897+0.j        ]]

[[ 0.89779286+0.j         -0.07524962+0.13954963j  0.1006792 +0.03293365j
   0.12542128-0.19918035j]
 [-0.07524962-0.13954963j  0.72635602+0.j         -0.08961216+0.2461997j
   0.32180163-0.03775482j]
 [ 0.1006792 -0.03293365j -0.08961216-0.2461997j   0.12342215+0.j
  -0.05137394-0.16026316j]
 [ 0.12542128+0.19918035j  0.32180163+0.03775482j -0.05137394+0.16026316j
   0.25242897+0.j        ]]

[[ 0.89779286+0.j         -0.07524962+0.13954963j  0.1006792 +0.03293365j
   0.12542128-0.19918035j]
 [-0.07524962-0.13954963j  0.72635602+0.j         -0.08961216+0.2461997j
   0.32180163-0.03775482j]
 [ 0.1006792 -0.03293365j -0.08961216-0.2461997j   0.12342215+0.j
  -0.05137394-0.16026316j]
 [ 0.12542128+0.19918035j  0.32180163+0.03775482j -0.05137394+0.16026316j
   0.25242897+0.j        ]]

Givens rotations with FQE

We can use the same circuit generated by OpenFermion except apply the Givens rotations to the FQE wavefunction. Each Givens rotation gate sequence is mapped to

\[ e^{-i \theta (a_{p}^{\dagger}a_{q} - a_{q}^{\dagger}a_{p})}e^{i \phi a_{p}^{\dagger}a_{p} } \]

which can be applies sequentially to the state.

# Get the basis rotation circuit
rotations, diagonal = givens_decomposition_square(u.conj().T)

# Reinitialize the wavefunction to Hartree-Fock
fqe_wfn = fqe.Wavefunction([[norbs // 2, norbs // 2, norbs]])
hf_wf = np.zeros((int(binom(norbs, norbs // 2)), 1),
                 dtype=np.complex128)
hf_wf[0, 0] = 1  # right most bit is zero orbital.
fqe_wfn.set_wfn(strategy='from_data',
                raw_data={(norbs // 2, norbs // 2): hf_wf})
# Iterate through each layer and time evolve by the appropriate fermion operators
for layer in rotations:
    for givens in layer:
        i, j, theta, phi = givens
        op = of.FermionOperator(((2 * j, 1), (2 * j, 0)), coefficient=-phi)
        fqe_wfn = fqe_wfn.time_evolve(1.0, op)

        op = of.FermionOperator(((2 * i, 1), (2 * j, 0)), coefficient=-1j * theta) + of.FermionOperator(((2 * j, 1), (2 * i, 0)), coefficient=1j * theta)
        fqe_wfn = fqe_wfn.time_evolve(1.0, op)

# evolve the last diagonal phases
for idx, final_phase in enumerate(diagonal):
    if not np.isclose(final_phase, 1.0):
        op = of.FermionOperator(((2 * idx, 1), (2 * idx, 0)), -np.angle(final_phase))
        fqe_wfn = fqe_wfn.time_evolve(1.0, op)
# map the wavefunction to a cirq wavefunction and get the opdm
crq_wfn = fqe.to_cirq(fqe_wfn).reshape((-1, 1))
crq_opdm = get_opdm(crq_wfn, 2 * norbs)
print(crq_opdm[::2, ::2])

# check if it is the same as our other 1-RDMs
assert np.allclose(final_opdm, crq_opdm[::2, ::2])
[[ 0.89779286+0.j         -0.07524962+0.13954963j  0.1006792 +0.03293365j
   0.12542128-0.19918035j]
 [-0.07524962-0.13954963j  0.72635602+0.j         -0.08961216+0.2461997j
   0.32180163-0.03775482j]
 [ 0.1006792 -0.03293365j -0.08961216-0.2461997j   0.12342215+0.j
  -0.05137394-0.16026316j]
 [ 0.12542128+0.19918035j  0.32180163+0.03775482j -0.05137394+0.16026316j
   0.25242897+0.j        ]]