pyQuil is for constructing and running hybrid quantum/classical algorithms on real quantum computers. With the release of pyQuil 2, we have changed parts of the API to take advantage of some exciting new features available on QCS.
A hybrid algorithm involves using the quantum computer to create a quantum state that would be difficult to prepare classically; measure it in a way particular to your problem; and then update your procedure for creating the state so that the measurements are closer to the correct answer. A real hybrid algorithm involves structured ansatze like QAOA for optimization or a UCC ansatz for chemistry. Here, we'll do a much simpler parameterized program
In [1]:
from pyquil import Program, get_qc
from pyquil.gates import *
def ansatz(theta):
program = Program()
program += RY(theta, 0)
return program
print(ansatz(theta=0.2))
For this extrordinarily simple ansatz, we can discretize the parameter theta and try all possible values. As the number of parameters increases, the number of combinations increases exponentially so doing a full grid search will become intractable for anything more than ~two parameters.
In [2]:
import numpy as np
qc = get_qc("9q-square-qvm")
thetas = np.linspace(0, 2*np.pi, 21)
results = []
for theta in thetas:
program = ansatz(theta)
bitstrings = qc.run_and_measure(program, trials=1000)
results.append(np.mean(bitstrings[0]))
In [3]:
%matplotlib inline
from matplotlib import pyplot as plt
plt.plot(thetas, results, 'o-')
plt.xlabel(r'$\theta$', fontsize=18)
_ = plt.ylabel(r'$\langle \Psi(\theta) | \frac{1 - Z}{2} | \Psi(\theta) \rangle$',
fontsize=18)
In [4]:
def objective_function(theta):
program = ansatz(theta[0])
bitstrings = qc.run_and_measure(program, trials=1000)
result = np.mean(bitstrings[0])
return -result
import scipy.optimize
res = scipy.optimize.minimize(objective_function, x0=[0.1], method='COBYLA')
res
Out[4]:
In [5]:
plt.plot(thetas, results, label='scan')
plt.plot([res.x], [-res.fun], '*', ms=20, label='optimization result')
plt.legend()
Out[5]:
Prior to QCS, a QPU job would be routed via a series of cloud-based queues and compilation steps. With Forest 2, you are in control of the two stages of compilation so you can amortize the cost of compiling. Your QMI and all classical infrastructure is hosted on the Rigetti premises, so network latency is minimal.
The first step of compilation converts gates to their hardware-supported equivalent. For example, our parametric RY is converted into RX's and RZ's because these are physically realizable on a Rigetti QPU
In [6]:
nq_program = qc.compiler.quil_to_native_quil(ansatz(theta=0.5))
print(nq_program)
The second step of compilation will turn named gates into calibrated pulses stored in a binary format suitable for consumption by the control electronics. This means that you can fully compile a given program and run it many times with minimal classical overhead.
Note: since we're using a QVM, for which there is no binary format, this stage is mocked out and you can see the original Quil inside the PyQuilExecutableResponse
that is returned. When running on the QPU, this will return a BinaryExecutableResponse
whose contents are opaque.
TODO: obscure the contents of PyQuilExecutableResponse
: https://github.com/rigetti/pyquil/issues/700
In [7]:
qc.compiler.native_quil_to_executable(nq_program)
Out[7]:
This doesn't buy us much if we have to know exactly what circuit we want to run before compiling it and amortizing the compilation cost. Maybe you could get away with it when you're doing a parameter scan, but for hybrid algorithms, the circuit parameter (here: theta
) depends on the results of a circuit before. This is the essence of hybrid programming! Therefore, all compilation steps have been upgraded to support named, symbolic parameters that will be updated at runtime with minimal overhead.
With this feature, you can compile a parametric program once and run it many times will different parameter values and you need not know the parameter values at compilation time.
There are a couple of prerequisites to use this feature effectively from PyQuil, which we address in this document.
First, you must declare a parameter when constructing your quil program. When declaring a named classical variable, you must specify at least a name and a type. It is conventional to make sure the Python variable name of the reference memory matches the Quil variable name. In our case, we name both things theta
. Our circuit above would be modified in this way:
In [8]:
program = Program()
theta = program.declare('theta', memory_type='REAL')
program += RY(theta, 0)
print(program)
In the documentation so far, we've been using the run_and_measure
functionality of QuantumComputer
. It's time to get our hands dirty and introduce explicit measure instructions.
Above, we declared a classical piece of memory, we've given it a name (theta
), and we've given it a type (REAL
). The bits that we measure (or "read out" -- ro
for short) must now also be declared, given a name, and a type. Additionally, we'll usually be measuring more than one qubit so we can give this register a size.
The index of the readout register need not match the qubit index. For example below, we create a bell state on qubits 5 and 6 and measure the readout results into ro[0]
and ro[1]
.
Note: The readout register must be named "ro" (for now)
In [9]:
program = Program()
ro = program.declare('ro', memory_type='BIT', memory_size=2)
program += H(5)
program += CNOT(5, 6)
program += MEASURE(5, ro[0])
program += MEASURE(6, ro[1])
print(program)
Our very simple ansatz only has one qubit, so the measurement is quite simple.
In [10]:
program = Program()
theta = program.declare('theta', memory_type='REAL')
ro = program.declare('ro', memory_type='BIT', memory_size=1)
program += RY(theta, 0)
program += MEASURE(0, ro[0])
print(program)
The number of trials is compiled into the executable binary, so we must specify this number prior to compilation.
TODO: add to str / repr https://github.com/rigetti/pyquil/issues/701
In [11]:
program = Program()
theta = program.declare('theta', memory_type='REAL')
ro = program.declare('ro', memory_type='BIT', memory_size=1)
program += RY(theta, 0)
program += MEASURE(0, ro[0])
program.wrap_in_numshots_loop(shots=1000)
print(program)
qc.run()
To use the lower-level but more powerful qc.run
interface, we have had to take control of these three things
ro
of type BIT
and included explicit MEASURE
instructions. Since this sets up a (potentially sprase) mapping from qubits to classical addresses, we can expect qc.run()
to return the classic 2d ndarray of yore instead of the dictionary returned by run_and_measure
program.wrap_in_numshots_loop()
prior to compilation so the number of shots can be encoded in an efficient binary representation of the programqc.compile(program)
or by using the lower-level functions:
nq_program = qc.compiler.quil_to_native_quil(program)
executable = qc.compiler.native_quil_to_executable(nq_program)
In [12]:
def ansatz(theta):
program = Program()
ro = program.declare('ro', memory_type='BIT', memory_size=1)
program += RY(theta, 0)
program += MEASURE(0, ro[0])
return program
print(ansatz(theta=np.pi))
We can run the program with a pre-set angle (here, theta = np.pi
).
In [13]:
program = ansatz(theta=np.pi)
program.wrap_in_numshots_loop(shots=5)
executable = qc.compile(program)
bitstrings = qc.run(executable)
print(bitstrings.shape)
bitstrings
Out[13]:
In [14]:
def ansatz():
program = Program()
theta = program.declare('theta', memory_type='REAL')
ro = program.declare('ro', memory_type='BIT', memory_size=1)
program += RY(theta, 0)
program += MEASURE(0, ro[0])
return program
print(ansatz())
In [15]:
program = ansatz() # look ma, no arguments!
program.wrap_in_numshots_loop(shots=1000)
executable = qc.compile(program)
thetas = np.linspace(0, 2*np.pi, 21)
results = []
for theta in thetas:
bitstrings = qc.run(executable, memory_map={'theta': [theta]})
results.append(np.mean(bitstrings[:, 0]))
%matplotlib inline
from matplotlib import pyplot as plt
plt.plot(thetas, results, 'o-')
plt.xlabel(r'$\theta$', fontsize=18)
_ = plt.ylabel(r'$\langle \Psi(\theta) | \frac{1 - Z}{2} | \Psi(\theta) \rangle$', fontsize=18)
In [16]:
program = ansatz() # look ma, no arguments!
program.wrap_in_numshots_loop(shots=1000)
executable = qc.compile(program)
def objective_function(thetas):
bitstrings = qc.run(executable, memory_map={'theta': thetas})
result = np.mean(bitstrings[:, 0])
return -result
res = scipy.optimize.minimize(objective_function, x0=[0.1], method='COBYLA')
res
Out[16]:
In [17]:
plt.plot(thetas, results, label='scan')
plt.plot([res.x], [-res.fun], '*', ms=20, label='optimization result')
plt.legend()
Out[17]: