Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Turns off QNode caching by default and makes variable values available during construction #209

Merged
merged 15 commits into from Jun 19, 2019
9 changes: 7 additions & 2 deletions pennylane/decorator.py
Expand Up @@ -114,7 +114,7 @@ def qfunc1(x):
from .qnode import QNode


def qnode(device, interface='numpy'):
def qnode(device, interface='numpy', cache=False):
"""QNode decorator.

Args:
Expand All @@ -131,12 +131,17 @@ def qnode(device, interface='numpy'):

* ``interface='tfe'``: The QNode accepts and returns eager execution
TensorFlow ``tfe.Variable`` objects.

cache (bool): If ``True``, the quantum function used to generate the QNode will
only be called to construct the quantum circuit once, on first execution,
and this circuit will be cached for all further executions. Only activate this
josh146 marked this conversation as resolved.
Show resolved Hide resolved
feature if your quantum circuit structure will never change.
"""
@lru_cache()
def qfunc_decorator(func):
"""The actual decorator"""

qnode = QNode(func, device)
qnode = QNode(func, device, cache=cache)

if interface == 'torch':
return qnode.to_torch()
Expand Down
98 changes: 76 additions & 22 deletions pennylane/qnode.py
Expand Up @@ -195,16 +195,23 @@ class QNode:
func (callable): a Python function containing :class:`~.operation.Operation`
constructor calls, returning a tuple of :class:`~.operation.Expectation` instances.
device (:class:`~pennylane._device.Device`): device to execute the function on
cache (bool): If ``True``, the quantum function used to generate the QNode will
only be called to construct the quantum circuit once, on first execution,
and this circuit will be cached for all further executions. Only activate this
co9olguy marked this conversation as resolved.
Show resolved Hide resolved
josh146 marked this conversation as resolved.
Show resolved Hide resolved
feature if your quantum circuit structure will never change.
"""
# pylint: disable=too-many-instance-attributes
_current_context = None #: QNode: for building Operation sequences by executing quantum circuit functions

def __init__(self, func, device):
def __init__(self, func, device, cache=False):
self.func = func
self.device = device
self.num_wires = device.num_wires
self.num_variables = None
self.ops = []

self.cache = cache

self.variable_ops = {}
""" dict[int->list[(int, int)]]: Mapping from free parameter index to the list of
:class:`Operations <pennylane.operation.Operation>` (in this circuit) that depend on it.
Expand Down Expand Up @@ -236,7 +243,7 @@ def _append_op(self, op):
raise QuantumFunctionError('State preparations and gates must precede expectation values.')
self.queue.append(op)

def construct(self, args, **kwargs):
def construct(self, args, kwargs=None):
"""Constructs a representation of the quantum circuit.

The user should never have to call this method.
Expand All @@ -250,20 +257,22 @@ def construct(self, args, **kwargs):
args (tuple): Represent the free parameters passed to the circuit.
Here we are not concerned with their values, but with their structure.
Each free param is replaced with a :class:`~.variable.Variable` instance.

.. note::

Additional keyword arguments may be passed to the quantum circuit function, however PennyLane
does not support differentiating with respect to keyword arguments. Instead,
keyword arguments are useful for providing data or 'placeholders' to the quantum circuit function.
kwargs (dict): Additional keyword arguments may be passed to the quantum circuit function,
however PennyLane does not support differentiating with respect to keyword arguments.
Instead, keyword arguments are useful for providing data or 'placeholders'
to the quantum circuit function.
"""
# pylint: disable=too-many-branches
# pylint: disable=too-many-branches,too-many-statements
self.queue = []
self.ev = [] # temporary queue for EVs

if kwargs is None:
kwargs = {}

# flatten the args, replace each with a Variable instance with a unique index
temp = [Variable(idx) for idx, val in enumerate(_flatten(args))]
self.num_variables = len(temp)
self.args_shape = args
josh146 marked this conversation as resolved.
Show resolved Hide resolved

# arrange the newly created Variables in the nested structure of args
variables = unflatten(temp, args)
Expand All @@ -283,14 +292,23 @@ def construct(self, args, **kwargs):
temp = [Variable(idx, name=key) for idx, _ in enumerate(_flatten(val))]
kwarg_variables[key] = unflatten(temp, val)
co9olguy marked this conversation as resolved.
Show resolved Hide resolved

Variable.free_param_values = np.array(list(_flatten(args)))
co9olguy marked this conversation as resolved.
Show resolved Hide resolved
Variable.kwarg_values = {k: np.array(list(_flatten(v))) for k, v in keyword_values.items()}

# set up the context for Operation entry
if QNode._current_context is None:
QNode._current_context = self
else:
raise QuantumFunctionError('QNode._current_context must not be modified outside this method.')
# generate the program queue by executing the quantum circuit function
try:
res = self.func(*variables, **kwarg_variables)
if self.cache:
# caching mode, must use variables for kwargs
# so they can be updated without reconstructing
res = self.func(*variables, **kwarg_variables)
else:
# no caching, fine to directly pass kwarg values
res = self.func(*variables, **keyword_values)
finally:
# remove the context
QNode._current_context = None
Expand Down Expand Up @@ -461,9 +479,27 @@ def evaluate(self, args, **kwargs):
Returns:
float, array[float]: output expectation value(s)
"""
if not self.ops:
# construct the circuit
self.construct(args, **kwargs)
if not self.ops or not self.cache:
if self.num_variables is not None:
# circuit construction has previously been called
if len(list(_flatten(args))) == self.num_variables:
# only construct the circuit if the number
# of arguments matches the allowed number
# of variables.
# This avoids construction happening
# via self._pd_analytic, where temporary
# variables are appended to the argument list.

# flatten and unflatten arguments
flat_args = list(_flatten(args))
shaped_args = unflatten(flat_args, self.args_shape)

# construct the circuit
self.construct(shaped_args, kwargs)
else:
# circuit has not yet been constructed
# construct the circuit
self.construct(args, kwargs)

# temporarily store keyword arguments
keyword_values = {}
Expand Down Expand Up @@ -582,9 +618,15 @@ def jacobian(self, params, which=None, *, method='B', h=1e-7, order=1, **kwargs)
if isinstance(params, numbers.Number):
params = (params,)

if not self.ops:
# remove jacobian specific keyword arguments
circuit_kwargs = {}
circuit_kwargs.update(kwargs)
for k in ('h', 'order', 'shots', 'force_order2'):
circuit_kwargs.pop(k, None)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think there should be a better way of separating the kwargs meant for QNode.func, and the ones meant for QNode methods. Maybe jacobian should receive the QNode.func kwargs as a separate dictionary argument?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this is possible, purely because autograd will always send the function keyword arguments as keyword arguments. What would work is instead to collect the Jacobian keyword arguments within a grad_options dictionary.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought about changing this here, but it seems out of scope. Should we mark this for a new PR?


if not self.ops or not self.cache:
# construct the circuit
self.construct(params, **kwargs)
self.construct(params, circuit_kwargs)

flat_params = np.array(list(_flatten(params)))

Expand Down Expand Up @@ -622,7 +664,7 @@ def check_method(m):
if 'F' in method.values():
if order == 1:
# the value of the circuit at params, computed only once here
y0 = np.asarray(self.evaluate(flat_params, **kwargs))
y0 = np.asarray(self.evaluate(params, **circuit_kwargs))
else:
y0 = None

Expand Down Expand Up @@ -658,19 +700,25 @@ def _pd_finite_diff(self, params, idx, h=1e-7, order=1, y0=None, **kwargs):
Returns:
float: partial derivative of the node.
"""
# remove jacobian specific keyword arguments
circuit_kwargs = {}
circuit_kwargs.update(kwargs)
for k in ('h', 'order', 'shots', 'force_order2'):
circuit_kwargs.pop(k, None)

shift_params = params.copy()
if order == 1:
# shift one parameter by h
shift_params[idx] += h
y = np.asarray(self.evaluate(shift_params, **kwargs))
y = np.asarray(self.evaluate(shift_params, **circuit_kwargs))
return (y-y0) / h
elif order == 2:
# symmetric difference
# shift one parameter by +-h/2
shift_params[idx] += 0.5*h
y2 = np.asarray(self.evaluate(shift_params, **kwargs))
y2 = np.asarray(self.evaluate(shift_params, **circuit_kwargs))
shift_params[idx] = params[idx] -0.5*h
y1 = np.asarray(self.evaluate(shift_params, **kwargs))
y1 = np.asarray(self.evaluate(shift_params, **circuit_kwargs))
return (y2-y1) / h
else:
raise ValueError('Order must be 1 or 2.')
Expand All @@ -692,6 +740,12 @@ def _pd_analytic(self, params, idx, force_order2=False, **kwargs):
Returns:
float: partial derivative of the node.
"""
# remove jacobian specific keyword arguments
josh146 marked this conversation as resolved.
Show resolved Hide resolved
circuit_kwargs = {}
circuit_kwargs.update(kwargs)
for k in ('h', 'order', 'shots', 'force_order2'):
circuit_kwargs.pop(k, None)

n = self.num_variables
w = self.num_wires
pd = 0.0
Expand Down Expand Up @@ -725,8 +779,8 @@ def _pd_analytic(self, params, idx, force_order2=False, **kwargs):
if not force_order2 and op.grad_method != 'A2':
# basic analytic method, for discrete gates and gaussian CV gates succeeded by order-1 observables
# evaluate the circuit in two points with shifted parameter values
y2 = np.asarray(self.evaluate(shift_p1, **kwargs))
y1 = np.asarray(self.evaluate(shift_p2, **kwargs))
y2 = np.asarray(self.evaluate(shift_p1, **circuit_kwargs))
y1 = np.asarray(self.evaluate(shift_p2, **circuit_kwargs))
pd += (y2-y1) * multiplier
else:
# order-2 method, for gaussian CV gates succeeded by order-2 observables
Expand Down Expand Up @@ -774,7 +828,7 @@ def tr_obs(ex):
# transform the observables
obs = list(map(tr_obs, self.ev))
# measure transformed observables
temp = self.evaluate_obs(obs, unshifted_params, **kwargs)
temp = self.evaluate_obs(obs, unshifted_params, **circuit_kwargs)
pd += temp

# restore the original parameter
Expand Down
65 changes: 63 additions & 2 deletions tests/test_qnode.py
Expand Up @@ -257,7 +257,12 @@ def qf(x):
def qf(x):
qml.RX(x, wires=[0])
return qml.expval.PauliZ(0)
q = qml.QNode(qf, self.dev2)

# in non-cached mode, the grad method would be
# recomputed and overwritten from the
# bogus value 'J'. Caching stops this from happening.
q = qml.QNode(qf, self.dev2, cache=True)

q.evaluate([0.0])
keys = q.grad_method_for_par.keys()
if len(keys) > 0:
Expand Down Expand Up @@ -559,7 +564,7 @@ def circuit(w):
assert np.allclose(circuit_output, expected_output, atol=tol, rtol=0)

# circuit jacobians
circuit_jacobian = circuit.jacobian(multidim_array)
circuit_jacobian = circuit.jacobian([multidim_array])
co9olguy marked this conversation as resolved.
Show resolved Hide resolved
expected_jacobian = -np.diag(np.sin(b))
assert np.allclose(circuit_jacobian, expected_jacobian, atol=tol, rtol=0)

Expand Down Expand Up @@ -916,3 +921,59 @@ def circuit(a, b):
[0., -np.sin(a[1])] + [0.] * 6, # expval 1
[0.] * 2 + [0.] * 5 + [-np.sin(b[2, 1])]]) # expval 2
assert np.allclose(circuit_jacobian, expected_jacobian, atol=tol, rtol=0)


class TestQNodeCacheing:
"""Tests for the QNode construction caching"""

def test_no_caching(self):
"""Test that the circuit structure changes on
subsequent evalutions with caching turned off
"""
dev = qml.device('default.qubit', wires=2)

def circuit(x, c=None):
qml.RX(x, wires=0)

for i in range(c):
qml.RX(x, wires=i)

return qml.expval.PauliZ(0)

circuit = qml.QNode(circuit, dev, cache=False)

# first evaluation
circuit(0, c=0)
# check structure
assert len(circuit.queue) == 1

# second evaluation
circuit(0, c=1)
# check structure
assert len(circuit.queue) == 2

def test_caching(self):
"""Test that the circuit structure does not change on
subsequent evalutions with caching turned on
"""
dev = qml.device('default.qubit', wires=2)

def circuit(x, c=None):
qml.RX(x, wires=0)

for i in range(c.val):
qml.RX(x, wires=i)

return qml.expval.PauliZ(0)

circuit = qml.QNode(circuit, dev, cache=True)

# first evaluation
circuit(0, c=0)
# check structure
assert len(circuit.queue) == 1

# second evaluation
circuit(0, c=1)
# check structure
assert len(circuit.queue) == 1
8 changes: 5 additions & 3 deletions tests/test_quantum_gradients.py
Expand Up @@ -396,7 +396,9 @@ def circuit(x,y,z):
autograd_val = grad_fn(*angle_inputs)
for idx in range(3):
onehot_idx = eye[idx]
manualgrad_val = (circuit(angle_inputs + np.pi / 2 * onehot_idx) - circuit(angle_inputs - np.pi / 2 * onehot_idx)) / 2
param1 = angle_inputs + np.pi / 2 * onehot_idx
param2 = angle_inputs - np.pi / 2 * onehot_idx
manualgrad_val = (circuit(*param1) - circuit(*param2)) / 2
co9olguy marked this conversation as resolved.
Show resolved Hide resolved
self.assertAlmostEqual(autograd_val[idx], manualgrad_val, delta=self.tol)

def test_qfunc_gradients(self):
Expand Down Expand Up @@ -465,13 +467,13 @@ def d_error(p, grad_method):
ret = 0
for d_in, d_out in zip(in_data, out_data):
args = np.array([d_in, p])
diff = (classifier(args) - d_out)
diff = (classifier(*args) - d_out)
co9olguy marked this conversation as resolved.
Show resolved Hide resolved
ret = ret + 2 * diff * classifier.jacobian(args, which=[1], method=grad_method)
return ret

y0 = error(param)
grad = autograd.grad(error)
grad_auto = grad([param])
grad_auto = grad(param)
co9olguy marked this conversation as resolved.
Show resolved Hide resolved

grad_fd1 = d_error(param, 'F')
grad_angle = d_error(param, 'A')
Expand Down