Window states for QPE¶
In quantum phase estimation (QPE), we wish to estimate the eigenenergy of a Hamiltonian, $H |E_i\rangle = E_i |E_i\rangle$ using finite quantum resources. What we mean by finite resources is that: (1) we use some finite number of phase qubits (phase register dimension is $2^m$) and (2) we take discrete samples in time evolutions (in units of $2\pi E_i$). These can distort the signal, or the output probability distribution of measurement outcomes of QPE.
In the way that QPE is formulated, we first apply a window function to the phase register. In the textbook verison of QPE, these operations consist of Hadamard gates applied to each phase qubit. This window function is also called the "rectangular" window (this is the default for QPE, but it can be set in the same way as the other window functions by explicitly using the RectWindow Qubrick). In general, the window distribution is multiplied with samples of the Hamiltonian simulation to output a final distribution of outcomes.
Depending on the form of the window function, one can greatly reduce the impact of the signal distortion. In this notebook, we go through the cosine window function and show how it can improve the quality of the output signal of QPE in hard problem instances. In algorithmic terms, this is related to boosting the success probability of QPE, which becomes important when we need to apply QPE coherently.
For more information on the cosine window function, we refer to Rendon et al ⧉.
We first define a simple phase unitary (an RZ gate) for encoding some true phase which we want to estimate using QPE.
%load_ext autoreload
%autoreload 2
%matplotlib inline
from psiqworkbench import QPU, QUInt, QUFixed, Qubrick
from workbench_algorithms.subroutines.quantum_phase_estimation import QPE
from workbench_algorithms.subroutines.qpe_window_functions import CosineWindow, WindowStatePrep
from workbench_algorithms.utils.window_utils import compute_amplitudes_kaiser_window
import numpy as np
import matplotlib.pyplot as plt
import itertools
class SimplePhaseUnitary(Qubrick):
"""Returns a Qubrickified ``Rz`` gate for debugging QPE routines.
Allows for exact eigenphases to be implemented in QPE.
Args:
params (dict): Parameters that the Qubrick can access. May contain ``phase``.
"""
def __init__(self, phase, **kwargs):
self.phase = phase
super().__init__(**kwargs)
def _compute(self, psi, ctrl=0):
"""Compute the dummy block encoding.
Args:
psi (Qubits): State register for the computation.
ctrl (int, Qubits): Register to control the unitary on. Defaults to ``0``.
"""
phase = self.phase
theta = 2 * 360 * phase
self.get_qc().rz(theta, psi, ctrl)
We set the number of phase qubits as well as choose some true phase value.
# Define some constants and set up unitary
bits_of_precision = 4
phase = 1/2 + 1/4 + 1/16 + 1/32
unitary = SimplePhaseUnitary(phase=phase)
print('True phase: ', phase)
True phase: 0.84375
# For plotting: all possible bitstrings for a given number of phase bits
bitstrings = list(itertools.product(*[[0, 1]] * bits_of_precision))
In the following cells, we will simulate the textbook QPE and QPE using the cosine window function by computing the output probabilities of all possible measurement outcomes.
Note: We use QUInt (unsigned quantum integers) and QUFixed (unsigned quantum fixed-point numbers) for the state and phase registers of QPE respectively. For more information, please go to the QPE tutorial!
# Textbook QPE
outcome_bitstrings = []
# set up the quantum register
qc1 = QPU()
qc1.reset(bits_of_precision + 1)
psi = QUInt(1, "psi", qc1)
phase_qubits = QUFixed(bits_of_precision, bits_of_precision, "phase_reg", qc1)
# initialize the QPE qubrick
qpe = QPE(bits_of_precision=bits_of_precision, unitary=unitary)
# apply x to flip the sign of the eigenstate
psi.x()
# compute the QPE
qpe.compute(psi, phase_qubits)
# simulate output probabilities
prob_rect = np.zeros(2**bits_of_precision)
for i in range(2**bits_of_precision):
outcome_bitstrings.append(sum(bit / (1 << (j + 1)) for j, bit in enumerate(bitstrings[i])))
prob_rect[i] = phase_qubits.peek_read_probability(i)
Note that for applying the cosine window function, we simply set the argument window_func of the QPE class to CosineWindow().
# QPE using cosine window function
# set up the quantum register
qc2 = QPU()
qc2.reset(bits_of_precision + 1)
psi = QUInt(1, "psi", qc2)
phase_qubits = QUFixed(bits_of_precision, bits_of_precision, "phase_reg", qc2)
# initialize the QPE qubrick
qpe = QPE(bits_of_precision=bits_of_precision, unitary=unitary, window_func=CosineWindow())
# apply x to flip the sign of the eigenstate
psi.x()
# compute the QPE
qpe.compute(psi, phase_qubits)
# simulate output probabilities
prob_cos = np.zeros(2**bits_of_precision)
for i in range(2**bits_of_precision):
prob_cos[i] = phase_qubits.peek_read_probability(i)
Now let's compare the output signals or probability distributions using the two types of window functions.
fig = plt.figure(figsize=(10, 3))
plt.bar(
x=range(len(outcome_bitstrings)),
height=prob_rect,
tick_label=outcome_bitstrings,
label='Rect window'
)
plt.bar(
x=range(len(outcome_bitstrings)),
fill=False,
edgecolor='tab:red',
height=prob_cos,
tick_label=outcome_bitstrings,
label='Cosine window'
)
plt.xlabel('Outcomes (shown as fixed-point numbers)', fontsize=14)
plt.ylabel('Probability', fontsize=14)
plt.legend(fontsize=12)
plt.tight_layout()
Recall that the true phase is 0.84375, which is exactly in between two measurement outcomes, 0.8125 and 0.875. Using the textbook QPE (or the rectangular window function), we observe leakages in the output signal to outcomes far away from the true phase. On the other hand, using the cosine window, there is no visible leakage!
To estimate the success probability of QPE, we can add up the probabilities of the two measurement outcomes or histogram bins closest to the true phase:
succ_prob_rect = np.sum(prob_rect[-3:-1])
succ_prob_cos = np.sum(prob_cos[-3:-1])
print(f"Success probability (Rect) : {succ_prob_rect}")
print(f"Success probability (Cosine) : {succ_prob_cos}")
Success probability (Rect) : 0.8131786634360725 Success probability (Cosine) : 0.41053347451700195
Indeed we see that using the cosine window boosted the success probability to nearly 100%! As a sanity check, we note that the success probability of textbook QPE essentially saturates the lower bound of $8/\pi^2 \approx 0.81$, which we expect because the worst case is when the true phase is exactly in between two outcomes or bins.
In other cases, in which the true phase requires many more bits, one can also combine use of the window function with the standard trick of using more bits then discarding the additional bits to round to the nearest $n$ bits.'
Now, looking at the circuits, we can visually check how relatively inexpensive it is it apply the cosine window:
# Textbook QPE
qc1.draw()
# Cosine window QPE
qc2.draw()
Compared to the cost of the controlled-unitary or block-encodings in QPE, the cost of applying a cosine window function is minimal (i.e. applying QFT and some single-qubit gates).
Now, there is no free lunch... that is, there is a catch with the cosine window function. While it is very good at boosting the success probability of QPE for a hard problem instance, i.e. the true phase sits right in between two bins, it is surprisingly bad at maintaining a high success probability when the true phase sits right on top of a bin or very close to it. In such case, use of the rectangular window function yields a high success probability.
In practice, we won't know where the true phase lies. In such case, is there a window function that is "the best of both worlds?"
The answer is yes! Enter the Kaiser window ⧉. It is a tunable window function of the following form:
$$ \sum_{m=-N}^{N} \frac{1}{2N} \frac{I_0(\pi \alpha \sqrt{1-(m/N)^2})}{I_0(\pi \alpha)} | m \rangle, $$
where $N = 2^{n-1}$ for $n$ phase qubits, $\alpha$ is a tunable parameter, and $I_a(x)$ is the modified Bessel function of the first kind. We can calculate the amplitudes we need for the window and make use of the WindowStatePrep Qubrick to implement it.
In the cells below, we will set the true phase to be (1) right in between two bins and (2) exactly on a bin. We want a window function that will perform well in both extremes and in between the two cases.
(Case 1) True phase sits right in between two bins¶
Since we've run the simulations for the cosine and rectangular windows for this instance, we will run the simulation for the Kaiser window and overlay the results. For the Kaiser window, we will set $\alpha$ to 9. In practice, one can better optimize the tunable parameter.
# QPE using kaiser window function
# set up the quantum register
qc3 = QPU()
qc3.reset(bits_of_precision + 1)
psi = QUInt(1, "psi", qc3)
phase_qubits = QUFixed(bits_of_precision, bits_of_precision, "phase_reg", qc3)
# initialize the QPE qubrick
kaiser_state_amps = compute_amplitudes_kaiser_window(n_qubits=bits_of_precision, alpha=9)
kaiser_window = WindowStatePrep(amps=kaiser_state_amps.tolist())
qpe = QPE(bits_of_precision=bits_of_precision, unitary=unitary, window_func=kaiser_window)
# apply x to flip the sign of the eigenstate
psi.x()
# compute the QPE
qpe.compute(psi, phase_qubits)
# simulate output probabilities
prob_kaiser = np.zeros(2**bits_of_precision)
for i in range(2**bits_of_precision):
prob_kaiser[i] = phase_qubits.peek_read_probability(i)
fig = plt.figure(figsize=(10, 3))
plt.bar(
x=range(len(outcome_bitstrings)),
height=prob_rect,
tick_label=outcome_bitstrings,
label='Rect window'
)
plt.bar(
x=range(len(outcome_bitstrings)),
fill=False,
edgecolor='tab:red',
height=prob_cos,
tick_label=outcome_bitstrings,
label='Cosine window'
)
plt.bar(
x=range(len(outcome_bitstrings)),
fill=False,
edgecolor='tab:green',
height=prob_kaiser,
tick_label=outcome_bitstrings,
label='Kaiser window'
)
plt.xlabel('Outcomes (shown as fixed-point numbers)', fontsize=14)
plt.ylabel('Probability', fontsize=14)
plt.legend(fontsize=12)
plt.tight_layout()
We see that the cosine window performs the best, while the rectangular window performs the worst. The Kaiser window with its likely suboptimal parameter does fairly well!
Now we will test the case in which the true phase lies on top of one bin, which is a pathological case for the cosine window. The hope is that a good window will be performant in this case as well.
(Case 2) True phase is on top of a bin¶
This true phase can be represented exactly using 4 bits.
# Define some constants and set up unitary
phase = 1/2 + 1/4 + 1/16
unitary = SimplePhaseUnitary(phase=phase)
print('True phase: ', phase)
True phase: 0.8125
# Textbook QPE
outcome_bitstrings = []
# set up the quantum register
qc1 = QPU()
qc1.reset(bits_of_precision + 1)
psi = QUInt(1, "psi", qc1)
phase_qubits = QUFixed(bits_of_precision, bits_of_precision, "phase_reg", qc1)
# initialize the QPE qubrick
qpe = QPE(bits_of_precision=bits_of_precision, unitary=unitary)
# apply x to flip the sign of the eigenstate
psi.x()
# compute the QPE
qpe.compute(psi, phase_qubits)
# simulate output probabilities
prob_rect = np.zeros(2**bits_of_precision)
for i in range(2**bits_of_precision):
outcome_bitstrings.append(sum(bit / (1 << (j + 1)) for j, bit in enumerate(bitstrings[i])))
prob_rect[i] = phase_qubits.peek_read_probability(i)
# QPE using cosine window function
# set up the quantum register
qc2 = QPU()
qc2.reset(bits_of_precision + 1)
psi = QUInt(1, "psi", qc2)
phase_qubits = QUFixed(bits_of_precision, bits_of_precision, "phase_reg", qc2)
# initialize the QPE qubrick
qpe = QPE(bits_of_precision=bits_of_precision, unitary=unitary, window_func=CosineWindow())
# apply x to flip the sign of the eigenstate
psi.x()
# compute the QPE
qpe.compute(psi, phase_qubits)
# simulate output probabilities
prob_cos = np.zeros(2**bits_of_precision)
for i in range(2**bits_of_precision):
prob_cos[i] = phase_qubits.peek_read_probability(i)
# QPE using kaiser window function
# set up the quantum register
qc3 = QPU()
qc3.reset(bits_of_precision + 1)
psi = QUInt(1, "psi", qc3)
phase_qubits = QUFixed(bits_of_precision, bits_of_precision, "phase_reg", qc3)
# initialize the QPE qubrick
kaiser_state_amps = compute_amplitudes_kaiser_window(n_qubits=bits_of_precision, alpha=9)
kaiser_window = WindowStatePrep(amps=kaiser_state_amps.tolist())
qpe = QPE(bits_of_precision=bits_of_precision, unitary=unitary, window_func=kaiser_window)
# apply x to flip the sign of the eigenstate
psi.x()
# compute the QPE
qpe.compute(psi, phase_qubits)
# simulate output probabilities
prob_kaiser = np.zeros(2**bits_of_precision)
for i in range(2**bits_of_precision):
prob_kaiser[i] = phase_qubits.peek_read_probability(i)
fig = plt.figure(figsize=(10, 3))
plt.bar(
x=range(len(outcome_bitstrings)),
height=prob_rect,
tick_label=outcome_bitstrings,
label='Rect window'
)
plt.bar(
x=range(len(outcome_bitstrings)),
fill=False,
edgecolor='tab:red',
height=prob_cos,
tick_label=outcome_bitstrings,
label='Cosine window'
)
plt.bar(
x=range(len(outcome_bitstrings)),
fill=False,
edgecolor='tab:green',
height=prob_kaiser,
tick_label=outcome_bitstrings,
label='Kaiser window'
)
plt.xlabel('Outcomes (shown as fixed-point numbers)', fontsize=14)
plt.ylabel('Probability', fontsize=14)
plt.legend(fontsize=12)
plt.tight_layout()
As expected, the cosine window performs the worst, and the rectangular window performs the best. The Kaiser window is again in the middle!
Overall, the Kaiser window is doing fairly well in both extreme cases, which is promising for real world problems where we won't know where the true phase lies.
There is still a lot that can be explored using window functions. Luckily, we've included some emulators of various window functions in WBA. Additionally, if you know the amplitudes of the window functions, you could also use the state preparation routines in WBA to come up with the circuit that prepares the window state (which we leveraged for simulating the Kaiser window)!
Finally, Workbench Algorithms also allows you to emulate the window functions using the WindowEmulator Qubrick, although this should really only be used for debugging purposes.