Optimized VQE Ansatz with qat.synthopline¶

In this notebook, we show how to generate an optimized UCCSD Ansatz for VQE using the qat.synthopline module. This module contains efficient tools to synthesize (parametrized) Trotter expansion of operators. It can be used to directly generate efficient jobs from a collection of cluster operators.

Generating the cluster operators¶

First, let us start by generating a necessary cluster operators and Hamiltonian for some molecule.

The following lines of code are directly extracted from another tutorial.

In [1]:
import numpy as np

lih_data = np.load("lih_data.npz", allow_pickle=True)

rdm1 = lih_data["rdm1"]
orbital_energies = lih_data["orbital_energies"]
nuclear_repulsion = lih_data["nuclear_repulsion"]
n_electrons = lih_data["n_electrons"]
one_body_integrals = lih_data["one_body_integrals"]
two_body_integrals = lih_data["two_body_integrals"]
info = lih_data["info"].tolist()

nqbits = rdm1.shape[0] * 2

from qat.fermion.chemistry import MolecularHamiltonian, MoleculeInfo
from qat.fermion.chemistry.ucc import guess_init_params, get_hf_ket, get_cluster_ops

# Define the molecular hamiltonian
mol_h = MolecularHamiltonian(one_body_integrals, two_body_integrals, nuclear_repulsion)

print(mol_h)
# Compute the natural orbitals occupation numbers and the basis transformation matrix
noons, basis_change = np.linalg.eigh(rdm1)

# The noons should be in decreasing order
noons = list(reversed(noons))

# Since we reversed the noons, we have to flip the basis as well
basis_change = np.flip(basis_change, axis=1)
# Change the hamiltonian basis
mol_h_new_basis = mol_h.transform_basis(basis_change)
molecule = MoleculeInfo(
    mol_h_new_basis,
    n_electrons=n_electrons,
    noons=noons,
    orbital_energies=orbital_energies,
)
# Selection of the active space
molecule.restrict_active_space(threshold_1=0.02, threshold_2=0.002)

# Compute the cluster operators
cluster_ops = get_cluster_ops(molecule.n_electrons, noons=molecule.noons)

# Computation of the initial  parameters
theta_list = guess_init_params(
    molecule.two_body_integrals,
    molecule.n_electrons,
    molecule.orbital_energies,
)

# Define the initial Hartree-Fock state
ket_hf_init = get_hf_ket(molecule.n_electrons, nqbits=molecule.nqbits)
# from qat.fermion.transforms import transform_to_bk_basis as transform
# from qat.fermion.transforms import recode_integer, get_bk_code as code
from qat.fermion.transforms import transform_to_jw_basis as transform
from qat.fermion.transforms import recode_integer, get_jw_code as code

# Compute the ElectronicStructureHamiltonian
H_active = molecule.hamiltonian.get_electronic_hamiltonian()

# Transform the ElectronicStructureHamiltonian into a spin Hamiltonian
H_active_sp = transform(H_active)


# Express the cluster operator in spin terms
# Here we group all cluster operators into a single obserable
cluster_ops_sp = sum(transform(t_o) for t_o in cluster_ops)

# Encoding the initial state to new encoding
hf_init_sp = recode_integer(ket_hf_init, code(H_active_sp.nbqbits))
 MolecularHamiltonian(
 - constant_coeff : 0.9071609330057144
 - integrals shape
    * one_body_integrals : (11, 11)
    * two_body_integrals : (11, 11, 11, 11)
)

Generating the Ansatz¶

Now that we have our cluster operators and our target Hamiltonian, we can use the qat.synthopline.generate_trotter_ansatz method to produce an Ansatz.

This method supports various backend algorithms. Here we will iterate over all of them and compare the output circuits.

In [2]:
from qat.synthopline.pauli_synth import generate_trotter_ansatz, STRATEGIES

def cnot_depth(job):
    """ Computes the CNOT/CZ depth of a job """
    depths = [0] * job.circuit.nbqbits
    for _, _, qbits in job.circuit.iterate_simple():
        if len(qbits) > 1:
            gate_depth = max(depths[q] for q in qbits) + 1
            for q in qbits:
                depths[q] = gate_depth
    return max(depths)

def cnot_count(job):
    """" Computes the number of CNOT/CZ in a job """
    stats = job.circuit.statistics()['gates']
    return stats.get('CNOT', 0) + stats.get('CSIGN', 0) + 3 * stats.get('SWAP', 0)



for strategy in STRATEGIES:
    job = generate_trotter_ansatz(cluster_ops_sp, final_observable=H_active_sp, init_state=hf_init_sp, strategy=strategy)
    print(strategy.ljust(17), cnot_count(job), cnot_depth(job))
pauli_synth       15 13
commute           28 26
commute_improved  16 15
naive             64 64
greedy            13 10
greedy_depth      12 8

A few things to notice !

First, the "naive" method is by far the worse strategy. This strategy is the one implemented in the qat.fermion package.

The "commute" method (the second worse here) corresponds to the published state of the art method.

All other methods are in-house methods only available in the QLM.

Notice that the "greedy_depth" method should be the default choice in that particular case since it achieves the best CNOT count AND CNOT depth.

Hence, by using the generate_trotter_ansatz one can bring the entangling count from 64 down to 12 and the entangling depth from 64 down to 8.

But we can probably do even better by further optimizing the output quantum circuit!

For this we will use the LazySynthesis plugin to compile the circuit for a all-to-all connectivity.

Pushing the limit¶

Let us try to gain a bit more !

In [3]:
from qat.synthopline import LazySynthesis
from qat.devices import AllToAll

best_job = generate_trotter_ansatz(cluster_ops_sp, final_observable=H_active_sp, init_state=hf_init_sp, strategy="greedy_depth")

device = AllToAll(cluster_ops_sp.nbqbits)
plugin = LazySynthesis(depth=3, merge=True, reorder=True, bidirectional=True, timeout=4, optimize_initial=True)

new_job, data = plugin.compile_job(best_job, device)
print("Final CNOT count:", cnot_count(new_job))
Final CNOT count: 11

We gained a CNOT gate thanks to LazySynthesis. It is not much, but still corresponds to a $8.3\%$ improvement compared to the previous best job.

Overall we reduced the CNOT count by about $83\%$ !