# FQE vs OpenFermion vs Cirq: Quadratic Hamiltonian Evolution

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.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],))

# 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        ]]

[{ "type": "thumb-down", "id": "missingTheInformationINeed", "label":"Missing the information I need" },{ "type": "thumb-down", "id": "tooComplicatedTooManySteps", "label":"Too complicated / too many steps" },{ "type": "thumb-down", "id": "outOfDate", "label":"Out of date" },{ "type": "thumb-down", "id": "samplesCodeIssue", "label":"Samples / code issue" },{ "type": "thumb-down", "id": "otherDown", "label":"Other" }]
[{ "type": "thumb-up", "id": "easyToUnderstand", "label":"Easy to understand" },{ "type": "thumb-up", "id": "solvedMyProblem", "label":"Solved my problem" },{ "type": "thumb-up", "id": "otherUp", "label":"Other" }]
{"lastModified": "Last updated 2024-06-30 UTC."}