New in Forest 2 - Parametric Programs

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))


RY(0.2) 0

Scan over the parameter (the old way)

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)


Do an optimization (the old way)

Instead of doing a full grid search, we will employ a classical optimizer to find the best parameter values. Here we use scipy to find the theta that results in sampling the most 1s in our resultant bitstrings.


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]:
     fun: -1.0
   maxcv: 0.0
 message: 'Optimization terminated successfully.'
    nfev: 13
  status: 1
 success: True
       x: array([3.1])

In [5]:
plt.plot(thetas, results, label='scan')
plt.plot([res.x], [-res.fun], '*', ms=20, label='optimization result')
plt.legend()


Out[5]:
<matplotlib.legend.Legend at 0x1015dedf28>

Compilation

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.

Quil to native quil

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)


PRAGMA EXPECTED_REWIRING "#(0 1 2 3 4 5 6 7 8)"
RX(pi/2) 0
RZ(0.5) 0
RX(-pi/2) 0
PRAGMA CURRENT_REWIRING "#(0 1 2 3 4 5 6 7 8)"
PRAGMA EXPECTED_REWIRING "#(0 1 2 3 4 5 6 7 8)"
PRAGMA CURRENT_REWIRING "#(0 1 2 3 4 5 6 7 8)"

Native quil to executable

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]:
PyQuilExecutableResponse(attributes={'native_quil_metadata': {'final-rewiring': [0, 1, 2, 3, 4, 5, 6, 7, 8], 'topological_swaps': 0, 'gate_depth': 3, 'gate_volume': 3, 'program_duration': 18.01, 'program_fidelity': 1.0, 'multiqubit_gate_depth': 0}, 'num_shots': 1}, program='PRAGMA EXPECTED_REWIRING "#(0 1 2 3 4 5 6 7 8)"\nRX(pi/2) 0\nRZ(0.5) 0\nRX(-pi/2) 0\nPRAGMA CURRENT_REWIRING "#(0 1 2 3 4 5 6 7 8)"\nPRAGMA EXPECTED_REWIRING "#(0 1 2 3 4 5 6 7 8)"\nPRAGMA CURRENT_REWIRING "#(0 1 2 3 4 5 6 7 8)"\n')

Parametric compilation

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)


DECLARE theta REAL[1]
RY(theta) 0

Measuring

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)


DECLARE ro BIT[2]
H 5
CNOT 5 6
MEASURE 5 ro[0]
MEASURE 6 ro[1]

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)


DECLARE theta REAL[1]
DECLARE ro BIT[1]
RY(theta) 0
MEASURE 0 ro[0]

Number of shots

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)


DECLARE theta REAL[1]
DECLARE ro BIT[1]
RY(theta) 0
MEASURE 0 ro[0]

Using qc.run()

To use the lower-level but more powerful qc.run interface, we have had to take control of these three things

  1. We decalred a read-out register named 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
  2. We have called program.wrap_in_numshots_loop() prior to compilation so the number of shots can be encoded in an efficient binary representation of the program
  3. We have taken control of compilation; either by calling qc.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))


DECLARE ro BIT[1]
RY(pi) 0
MEASURE 0 ro[0]

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


(5, 1)
Out[13]:
array([[1],
       [1],
       [1],
       [1],
       [1]])

Scan over the parameter (the new way)

Finally, all the pieces are in place to compile and run parameterized executable binaries. We declare parameters that will be compiled symbolically into the binary allowing us to amortize the cost of compilation when running hybrid algorithms.


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())


DECLARE theta REAL[1]
DECLARE ro BIT[1]
RY(theta) 0
MEASURE 0 ro[0]

Using memory_map

Now, when we call qc.run we provide a memory_map argument which will substitute in values for previously-declared Quil variables in a pre-compiled executable.


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)


Do an optimization (the new way)

Since parameters are compiled symbolically, we can do hybrid algorithms just as fast as parameter scans.


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]:
     fun: -1.0
   maxcv: 0.0
 message: 'Optimization terminated successfully.'
    nfev: 12
  status: 1
 success: True
       x: array([3.1])

In [17]:
plt.plot(thetas, results, label='scan')
plt.plot([res.x], [-res.fun], '*', ms=20, label='optimization result')
plt.legend()


Out[17]:
<matplotlib.legend.Legend at 0x1015f13898>