Statistical Phase Estimation
In this tutorial, we will go over how to execute statistical phase estimation (SPE) using QURI Algo and QURI VM.
Overview
SPE is an algorithm that is used to estimate the eigenenergies of a problem Hamiltonian. It does so by performing a Hadamard test on a trial wave-function, resulting in a measurable signal that can be used to infer the wave-functions spectral density. If this wave-function has a significant overlap with the true ground state of the system, the ground state energy should make a significant contribution to the spectral density. The following propoerties makes SPE an attractive algorithm in EFTQC
- Good circuit depth scaling compared to FTQC algorithms
- The run-time scales with the energy accuracy as
- Noise resilience
In this notebook we will introduce all of the pieces needed to run SPE. In order, this notebook will
- Explain the LT22 and Gaussian filter variants of SPE and showcase their implementations in QURI Algo
- Instruct in the use of QURI Parts' noise models and showcase the robustness of SPE to noise based on the STAR architecture
- Estimate the Fidelity of the Hadamard test circuits used in SPE after transpilation to the STAR architecture and introduce
circuit_cost_estimator
Prerequisites
We do not go into great detail about the following topics, but they will be part of this notebook.
Set up the system
Before running SPE we briefly introduce some of the building blocks we need. We will here show how to
- Define a molecule
- Set up programming abstractions needed for SPE using QURI Algo
- Verify that they behave as expected by conducting the Hadamard test
We first set up the problem using PySCF and quri-parts. PySCF is used to define the molecular geometry and then give us the spin-restricted Hartree-Fock ground state solution to the molecular Hamiltonian. Then the second quantized Hamiltonian is generated by calculating the electron integrals, which is then converted to a qubit operator. For this we use the quri_parts.openfermion
and quri_parts.pyscf
modules.
from pyscf import gto, scf
from quri_parts.pyscf.mol import get_spin_mo_integrals_from_mole
from quri_parts.openfermion.mol import get_qubit_mapped_hamiltonian
from quri_algo.problem import QubitHamiltonianInput
# Prepare Hamiltonian
mole = gto.M(atom="H 0 0 0; H 0 0 1")
mf = scf.RHF(mole).run(verbose=0)
hamiltonian, mapping = get_qubit_mapped_hamiltonian(
*get_spin_mo_integrals_from_mole(mole, mf.mo_coeff)
)
Before moving on, we compute some exact system characteristics, e.g. the exact ground state energy and the exact energy gap between the ground state and the first excited state. To this end, we first convert the Hamiltonian to a sparse matrix that we can diagonalize using scipy's sparse linear algebra library.
import numpy as np
from quri_parts.core.operator import get_sparse_matrix
vals, vecs = np.linalg.eigh(get_sparse_matrix(hamiltonian).toarray())
EXACT_GS_ENERGY = np.min(vals)
EXACT_GAP = np.abs(vals[np.argsort(vals)][:2] @ [1, -1])
print("E_0:", EXACT_GS_ENERGY)
print("Delta:", EXACT_GAP)
# output:
E_0: -1.1011503302326187
Delta: 0.3552785371970515
To perform further computations, we need to encode our problem into some circuit that represents some operator function of our problem. For example in SPE, to estimate the ground state of a Hamiltonian, we need to encode our Hamiltonian into a controlled time evolution circuit. Here, we choose to do the time evolution with Trotterization. To do this, we wrap the hamiltonian into QubitHamiltonianInput
for encoding into a circuit later.
QubitHamiltonianInput
can be seen as a generalization of the Operator
class from QURI Parts, containing both the terms of a qubit operator and the number of qubits. This makes it convenient when contructing a class hierarchy with quantum circuit factories intended to represent time-evolution and other problem defined quantum circuit constructs.
hamiltonian_input = QubitHamiltonianInput(mapping.n_qubits, hamiltonian)
Circuit factories
As a direct example of the aforementioned circuit factories we consider the TrotterControlledTimeEvolutionCircuitFactory
. As the name suggests this is a circuit factory that instance time-evolution circuits based on the Trotter-Suzuki decomposition. It needs a QubitHamiltonianInput
which serves as the generator of time evolution, as well as the number of Trotter steps. Details of this factory are explained in circuit factories.
from quri_parts.circuit.utils.circuit_drawer import draw_circuit
from quri_algo.circuit.time_evolution.trotter_time_evo import (
TrotterControlledTimeEvolutionCircuitFactory,
)
trotter_concotrolled_time_evo_circuit_factory = (
TrotterControlledTimeEvolutionCircuitFactory(hamiltonian_input, n_trotter=50)
)
# Time-evolution with t=10
c_time_evo = trotter_concotrolled_time_evo_circuit_factory(evolution_time=1)
# draw_circuit(c_time_evo, line_length=10000)
As seen above, the time-evolution is composed of a series of Pauli rotations. Since we chose a single Trotter step and since it is a controlled time-evolution, each term in the Hamiltonian roughly results in two Pauli rotations. We represent these Pauli rotations as single quantum gates, however to run them on a real device, in most cases, they need to first be transpiled to a series of two-qubit gates and single qubit rotations. Later in this notebook, we will show how this is done to conform to the STAR architecture.
Estimator
The SPE algorithm requires an esimtation of on different evolution time . Follow the estimator tutorial, we build the exact estimator and another one that based on Hadamard test with Trotterized time evolution operator.
from quri_algo.core.estimator.time_evolution_estimator.exact_spectrum import (
ExactTimeEvolutionExpectationValueEstimator
)
from quri_algo.core.estimator.time_evolution_estimator.trotter import (
TrotterTimeEvolutionHadamardTest
)
from quri_parts.qulacs.sampler import create_qulacs_vector_sampler
sampler = create_qulacs_vector_sampler()
exact_time_evolution_estimator = ExactTimeEvolutionExpectationValueEstimator(
hamiltonian_input, vals, vecs
)
trotter_time_evolution_estimator = TrotterTimeEvolutionHadamardTest(
hamiltonian_input, sampler, 50
)
We then build a state as an example. We choose a computational basis superposition state.
from quri_parts.core.state import quantum_state, comp_basis_superposition
state = comp_basis_superposition(
quantum_state(mapping.n_qubits, bits=0b0011),
quantum_state(mapping.n_qubits, bits=0b1100),
7 / 20,
0,
)
Let's compute with the state above. First we use the exact estimator, which is based on matrix multiplication
print(
f"The time-evolution expectation value is {exact_time_evolution_estimator(state, evolution_time=5.).value}"
)
print(
f"The trotter time-evolution expectation value is {trotter_time_evolution_estimator(state, evolution_time=5., n_shots=int(1e6)).value}"
)
# output:
The time-evolution expectation value is (0.7803970841187445-0.5734363633936669j)
The trotter time-evolution expectation value is (0.780776-0.573478j)
As we shall see below, this Hadamard test is essential to SPE. The tools provided above can be used to dictate whether the Hadamard test should be done exactly or with a Trotterized quantum circuit implementation based on sampling.
Executing statistical phase estimation
SPE has two main variants, which we will explore in this notebook. The first one is by Lin and Tong (LT22) which introduced SPE in the context of early fault tolerant devices for the first time. We also explore a variant of it by Wang et. al. which we refer to as Gaussian filter. The chief difference between the two is that the signal that is generated uses a convolution function that is either a Heaviside step functino (LT22) or a Gaussian (Gaussian filter).
Introducing the signal and convolution functions
SPE tries to obtain the spectrum of a Hamiltonian by using a trial state . `s overlap with the eigenstates of the Hamiltonian is . In terms of this overlap the spectral density function is
where is the Dirac delta function and is a dimensionless scaling parameter applied to the Hamiltonian, that we introduce through the algorithm itself.
Depending on the concrete implementation of SPE, we may never directly resolve the spectral density function exactly, but we construct a signal that will have features in common with it. This is done by first picking a convolution function such that when the spectral density is convolved by , the eigenvalues manifest as special points in the resulting signal
Here defines the support of , while determines where the eigenvalues are laid out in the resulting signal. We here skip the full derivation, but the interested reader is directed to our paper summarizing these ideas while providing benchmarks of SPE. Here it suffices to say that we can express this convolution in terms of the Fourier components as
Here is a parameter that gives the upper bound on the number of Fourier modes included in the algorithm. From this expression and the preceeding discussion we see that this signal can be recovered using a quantum processor. The expectation value of the time-evolution operator can be evaluated using the Hadamard test. The only other component we need is a Fourier transform of the convolution function. The convolution function itself can be chosen depending on desired algorithm scaling.
The convolution function is only approximately represented in the convolution integral because of the cut-off . This defines a trade-off between the accuracy of the convolution function and the run-time needed to accurately represent time-evolution by .
Finally, the scaling of the integrand with is not carried out by direct multiplication. Instead is sampled to determine the number of samples used to evaluate .
In the following we will show how to proceed with QURI Algo's SPE implementation with the LT22 variant is chosen in which is a Heaviside step-function.
The LT22 algorithm
We start with the LT22 variant. First we have to specify the maximum index of the Fourier modes considered, , a parameter that controls the interval within which the error of the Fourier transformed Heaviside step function is bounded. In addition, we specify . As mentioned, effectively scales first axis of the resulting signal, thus acting as a parameter for fine-graining the signal. The consequence of choosing a small is that the signal itself may not include the ground state energy or other eigenenergies of interest, however, choosing it too large will render the resolution too low to effectively distinguish different peaks in the signal.
max_fourier = 1000
delta = 1e-4
n_sample = 10000
tau = 1 / 20
from quri_algo.algo.phase_estimation.spe import StepFunctionParam
from quri_algo.algo.phase_estimation.spe.lt22 import SingleSignalLT22GSEE
from pprint import pprint
eta = 0.4
signal_param = StepFunctionParam(d=1000, delta=1e-4, n_sample=10000)
lt22_algorithm = SingleSignalLT22GSEE(trotter_time_evolution_estimator, tau=tau)
spe_result = lt22_algorithm(state, signal_param, eta)
lt22_gs_energy = spe_result.phase / tau
print(f"The obtained ground state energy is {lt22_gs_energy}")
print(f"The error is = {abs(lt22_gs_energy - np.min(vals))}")
# output:
The obtained ground state energy is -1.1067624476952391
The error is = 0.0056121174626204695
and can be computed by passing in the list of signal functions to the SPEGSEERecorder
.
from quri_algo.algo.phase_estimation.spe.utils.recorder import SPEGSEERecorder
resource = SPEGSEERecorder(spe_result.signal_functions, tau)
pprint(resource)
# output:
SPEGSEERecorder(max_evolution_time=np.float64(49.95),
total_evolution_time=np.float64(102955.90000000005),
n_shots=np.int64(20000))
When the algorithm is executed, the signal is automatically saved in the algorithm object, where we can plot out the signal at any derivative order
import matplotlib.pyplot as plt
xs = np.linspace(-np.pi / 10, np.pi / 10, 10000)
signal_func = spe_result.signal_functions[0]
ys = np.abs(signal_func(xs, derivative_order=0))
yds = np.abs(signal_func(xs, derivative_order=1))
fig, (ax1, ax2) = plt.subplots(1, 2)
ax1.plot(
xs,
ys,
lw=3,
label=r"$Z_F(x; \tau)$",
)
ax1.axvline(np.min(vals) * tau, color="red", linestyle="--", label="Exact")
ax1.legend(fontsize=20)
ax2.plot(xs, yds, lw=3, label=r"$Z'_F(x; \tau)$")
ax2.axvline(np.min(vals) * tau, color="red", linestyle="--", label="Exact")
ax2.legend()
ax2.legend(fontsize=20)
for ax in [ax1, ax2]:
ax.tick_params(axis="both", which="major", labelsize=16)
fig.set_size_inches(20, 8)
plt.show()
As seen above, SPE with the step function convolution function results in fairly distinguishable signatures of the spectral density function peaks. These appear as steps in the raw signal and as peaks in the first order derivative. If the calculation is carried out exactly, the first order derivative is seen to result in the actual spectral density. However, due to sampling noise and the limit on Fourier modes the resulting signal carries significant noise.
That being said, for the above signal these errors do not appear to significantly alter the centroids of the resulting peaks. As long as the overlap is such that each peak retains a height well above the noise floor it appears that LT22 is robust to noise.
Gaussian
The Gaussian filter variant of SPE is overall similar to LT22 and the flow is the same apart from some classical post-processing steps. However, Gaussian filter is sensitive to the parameter choices made since the eigenenergies manifest as Gaussians in the returned signal. The standard deviation of each Gaussian for instance influences the energy resolution possible. Below we define a function that returns optimal metaparameters based on the paper by Wang et. al.
from typing import Optional
from quri_algo.algo.phase_estimation.spe import GaussianParam
from quri_algo.algo.phase_estimation.spe.gaussian import GaussianFittingGSEE
from quri_algo.algo.phase_estimation.spe.utils.fourier.gaussian_coefficient import (
GaussianSampler,
)
def get_recommended_gaussian_parameter(
gap: float,
eps: float,
overlap: float,
delta: float,
n_discretize: int,
max_shot_limit: Optional[int] = None,
) -> tuple[GaussianParam, int]:
Delta = gap
sigma = np.min(
[
0.9 * Delta / np.sqrt(2 * np.log(9 * Delta * eps**-1 * overlap**-1)),
0.2 * Delta,
]
)
eps_tilde = 0.1 * eps * overlap / (np.sqrt(2 * np.pi) * sigma**3)
T = (
np.pi**-1
* sigma**-1
* np.sqrt(2 * np.log(8 * np.pi**-1 * eps_tilde**-1 * sigma**-2))
)
M = np.ceil(sigma / eps) + 1
fk = GaussianSampler(T, n_discretize, sigma).fourier_coefficients
shot_const = np.abs(fk).sum()
S = int(
shot_const**2 * np.ceil(np.log(4 * M / (delta / 2)) / (eps_tilde / 2) ** 2)
)
if max_shot_limit is not None:
S = np.min([S, max_shot_limit])
return (
GaussianParam(T, n_discretize, sigma, S),
M,
)
In order to use this, we must provide an upper bound on the excitation gap of the system. Although we can obtain it fairly easily for the above Hamiltonian, we do not in general expect to be able to solve the problem exactly. So we will instead use the coupled cluster excitation energy as a rough estimate. We can calculate it by using the equation of motion CCSD using PySCF
from pyscf import cc
ccsd = cc.CCSD(mf).run()
e_ee, _ = ccsd.eeccsd(nroots=2)
print("EOMCCSD excitation energy is:", e_ee[0])
# output:
E(CCSD) = -1.101150330244479 E_corr = -0.03504168092654223
EOMCCSD excitation energy is: 0.3552785372089112
<class 'pyscf.cc.ccsd.CCSD'> does not have attributes converged
We now obtain the parameters
gaussian_param, n_scanned_energy = get_recommended_gaussian_parameter(
e_ee[0],
eps=1e-6,
overlap=0.5,
delta=1e-4,
n_discretize=1000,
max_shot_limit=1e7,
)
pprint(gaussian_param)
# output:
GaussianParam(T=np.float64(31.331137453515634),
N=1000,
sigma=np.float64(0.057114676949872885),
n_sample=np.float64(10000000.0))
Before launching the Gaussian filter variation of SPE let's see if we can get a rough idea of what the eigenenergies are with LT22 and then based on that refine the search criteria of the Gaussian filter method.
rough_signal_param = StepFunctionParam(d=100, delta=1e-4, n_sample=10000)
rough_energy = lt22_algorithm(state, rough_signal_param, eta=0.5).phase / tau
print(f"Rough energy estimation by LT22 = {rough_energy}")
print(f"Energy error = {rough_energy - np.min(vals)}")
# output:
Rough energy estimation by LT22 = -1.1673438044412443
Energy error = -0.06619347420862565
This should help narrow down the interval to search for the true ground state energy.
gaussian_spe_algorithm = GaussianFittingGSEE(trotter_time_evolution_estimator, tau)
search_range = gaussian_spe_algorithm.get_recommended_search_range(
rough_energy * tau, n_scanned_energy, gaussian_param.sigma, 10
)
gaussian_result = gaussian_spe_algorithm(state, gaussian_param, search_range, eta=0.5)
gaussian_energy = gaussian_result.phase / tau
print("Gaussian energy:", gaussian_energy)
print("Energy error:", np.abs(gaussian_energy - EXACT_GS_ENERGY))
# output:
Gaussian energy: -0.8452554910894953
Energy error: 0.2558948391431234
gaussian_resource = SPEGSEERecorder(gaussian_result.signal_functions, tau)
pprint(gaussian_resource)
# output:
SPEGSEERecorder(max_evolution_time=np.float64(0.7550804126297271),
total_evolution_time=np.float64(2223559.6273274785),
n_shots=np.int64(20000000))
As opposed to LT22, the Gaussian filter method ideally results in a signal which contains a set of Gaussians that are centered on each eigenvalue. Here we have done enough pre-processing to narrow down the centroid quite a lot and will only focus on the ground state energy. The Gaussian will be flat at its peak, and because of that we can obtain the ground state energy by finding the zeros of the first derivative of the signal.
xs = search_range
signal_func = gaussian_result.signal_functions[0]
ys = np.abs(signal_func(xs, derivative_order=0))
yds = np.real(signal_func(xs, derivative_order=1))
fig, (ax1, ax2) = plt.subplots(1, 2)
ax1.plot(
xs,
ys,
lw=3,
label=r"$Z_F(x; \tau)$",
)
ax1.axvline(np.min(vals) * tau, color="red", linestyle="--", label="Exact")
ax1.legend(fontsize=20)
ax2.plot(xs, yds, lw=3, label=r"$Z'_F(x; \tau)$")
ax2.axvline(np.min(vals) * tau, color="red", linestyle="--", label="Exact")
ax2.legend()
ax2.legend(fontsize=20)
# plt.hlines()
for ax in [ax1, ax2]:
ax.tick_params(axis="both", which="major", labelsize=16)
fig.set_size_inches(20, 8)
fig.suptitle(
"$T_{max}$="
f"{gaussian_resource.max_evolution_time: .2f}, "
"$T_{total}$="
f"{gaussian_resource.total_evolution_time: .2e}, "
"$N_{shots}$="
f"{gaussian_resource.n_shots: .2e}",
size=20,
)
plt.show()