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

Add adjoint differentiation method #1032

Merged
merged 44 commits into from
Jan 26, 2021
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
9fc4301
Move rewind diff method to device
josh146 Jan 24, 2021
994a732
Move rewind diff method to device
josh146 Jan 24, 2021
f402118
more
josh146 Jan 24, 2021
a426cca
Fix import
trbromley Jan 24, 2021
0c62766
Merge branch 'master' into rewind-on-device
trbromley Jan 25, 2021
ccd080b
Apply suggestions
trbromley Jan 25, 2021
d823c52
Add to changelog
trbromley Jan 25, 2021
8cccca8
Rename rewind to adjoint
trbromley Jan 25, 2021
89a78fa
Add tests
trbromley Jan 25, 2021
5b2e094
Add tests
trbromley Jan 25, 2021
902514f
Add tests
trbromley Jan 25, 2021
1ee5eeb
Remove spacing
trbromley Jan 25, 2021
fe35a8b
Add to tests
trbromley Jan 25, 2021
91c75ad
Add skips
trbromley Jan 25, 2021
ef67430
Fix CI
trbromley Jan 25, 2021
9ad2026
Fix CI
trbromley Jan 25, 2021
8982090
Add docstring
trbromley Jan 25, 2021
55afc93
Respond to comments
trbromley Jan 25, 2021
2f728bf
Fix test
trbromley Jan 25, 2021
a66ee08
Add docstring
trbromley Jan 25, 2021
ad2728b
Change order in docstring
trbromley Jan 25, 2021
1e6033c
Update sum
trbromley Jan 25, 2021
2073a85
Tidy
trbromley Jan 25, 2021
dfb6afa
Update pennylane/_qubit_device.py
trbromley Jan 25, 2021
e0a9d85
Move operation_derivative
trbromley Jan 25, 2021
6c0daa6
Apply black
trbromley Jan 25, 2021
e975cef
Tidy imports
trbromley Jan 25, 2021
21ba5c7
Reword
trbromley Jan 25, 2021
4cf10fc
Move position
trbromley Jan 25, 2021
523997e
Update docstring:
trbromley Jan 25, 2021
ad042a1
Apply suggestions from code review
trbromley Jan 26, 2021
ce7865f
Add test for coverage
trbromley Jan 26, 2021
d1e24b5
Merge branch 'master' into rewind-on-device
josh146 Jan 26, 2021
ff5123c
Merge branch 'master' into rewind-on-device
trbromley Jan 26, 2021
d098434
Add note
trbromley Jan 26, 2021
8235ca6
Update import
trbromley Jan 26, 2021
46daf2a
Update pennylane/_qubit_device.py
trbromley Jan 26, 2021
0501bf2
Move tests
trbromley Jan 26, 2021
561b473
Merge branch 'rewind-on-device' of github.com:XanaduAI/pennylane into…
trbromley Jan 26, 2021
6f9d1d8
tidy
trbromley Jan 26, 2021
83c47ae
Update
trbromley Jan 26, 2021
c431279
Fix test
trbromley Jan 26, 2021
f5f20b7
Update docstring
trbromley Jan 26, 2021
e585da5
Update docstring
trbromley Jan 26, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .github/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,33 @@

<h3>New features since last release</h3>

* A new differentiation method has been added for use with simulators in tape mode. The `"adjoint"`
method operates after a forward pass by iteratively applying the inverse gate to scan backwards
through the circuit. This method is similar to the reversible method, but has a lower time
overhead and a similar memory overhead. It follows the approach provided by
[Jones and Gacon](https://arxiv.org/abs/2009.02823).
trbromley marked this conversation as resolved.
Show resolved Hide resolved

Example use:

```python
import pennylane as qml

qml.enable_tape()
trbromley marked this conversation as resolved.
Show resolved Hide resolved

wires = 1
device = qml.device("default.qubit", wires=wires)

@qml.qnode(device, diff_method="adjoint")
def f(params):
qml.RX(0.1, wires=0)
qml.Rot(*params, wires=0)
qml.RX(-0.3, wires=0)
return qml.expval(qml.PauliZ(0))

params = [0.1, 0.2, 0.3]
qml.grad(f)(params)
```

* Added `qml.math.squeeze`.
[(#1011)](https://github.com/PennyLaneAI/pennylane/pull/1011)

Expand Down
110 changes: 109 additions & 1 deletion pennylane/_qubit_device.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,10 @@

import numpy as np

import pennylane as qml
from pennylane.operation import Sample, Variance, Expectation, Probability, State
from pennylane.qnodes import QuantumFunctionError
from pennylane import Device
from pennylane import Device, math
from pennylane.wires import Wires


Expand Down Expand Up @@ -688,3 +689,110 @@ def sample(self, observable):
unraveled_indices = [2] * len(device_wires)
indices = np.ravel_multi_index(samples.T, unraveled_indices)
return observable.eigvals[indices]

def adjoint_jacobian(self, tape):
"""Implements the method outlined in https://arxiv.org/abs/2009.02823 to calculate the
Jacobian."""
trbromley marked this conversation as resolved.
Show resolved Hide resolved

for m in tape.measurements:
if m.obs is None:
raise ValueError(f"Adjoint differentiation method does not support measurement {m}")

if not hasattr(m.obs, "base_name"):
m.obs.base_name = None # This is needed for when the observable is a tensor product
josh146 marked this conversation as resolved.
Show resolved Hide resolved

# Perform the forward pass
self.reset()

# Consider using caching and calling lower-level functionality. We just need the state
# without postprocessing https://github.com/PennyLaneAI/pennylane/pull/1032/files#r563441040
self.execute(tape)
trbromley marked this conversation as resolved.
Show resolved Hide resolved

phi = self._reshape(self.state, [2] * self.num_wires)

lambdas = [self._apply_operation(phi, obs) for obs in tape.observables]
trbromley marked this conversation as resolved.
Show resolved Hide resolved
Copy link
Member

Choose a reason for hiding this comment

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

Just double checking, but we can't do the following:

new_tape = some_func(old_tape)
self.execute(new_tape)

because here we are applying hermitian matrices to the statevector, which the tape/device won't understand how to do without going low level?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The new_tape for a specific obs would look like:

with qml.QuantumTape() as new_tape:
    qml.QubitStateVector(phi, wires=range(wires))
    obs  # actually not sure how this line would look
    qml.state()

I don't see a problem if it's PauliX but for an arbitrary Hermitian this wouldn't (?) work. Maybe we could hack it in to work though, but we'd lose the state being normalized.

I also feel like the "many-tapes" approach might be inefficient, e.g., to make lots of tapes and then do device execution (which includes lots of postprocessing), when we just need to evolve the state by one gate.


jac = np.zeros((len(tape.observables), len(tape.trainable_params)))
trbromley marked this conversation as resolved.
Show resolved Hide resolved

expanded_ops = []
for op in reversed(tape.operations):
if op.num_params > 1:
if isinstance(op, qml.Rot) and not op.inverse:
trbromley marked this conversation as resolved.
Show resolved Hide resolved
ops = op.decomposition(*op.parameters, wires=op.wires)
expanded_ops.extend(reversed(ops))
else:
raise QuantumFunctionError(
f"The {op.name} operation is not supported using "
'the "adjoint" differentiation method'
)
else:
expanded_ops.append(op)

expanded_ops = [o for o in expanded_ops if not o.name in ("QubitStateVector", "BasisState")]
trbromley marked this conversation as resolved.
Show resolved Hide resolved
dot_product_real = lambda a, b: self._real(math.sum(self._conj(a) * b))
trbromley marked this conversation as resolved.
Show resolved Hide resolved

param_number = len(tape._par_info) - 1
trainable_param_number = len(tape.trainable_params) - 1
for op in expanded_ops:

if op.grad_method and param_number in tape.trainable_params:
trbromley marked this conversation as resolved.
Show resolved Hide resolved
d_op_matrix = operation_derivative(op)

op.inv()
trbromley marked this conversation as resolved.
Show resolved Hide resolved
phi = self._apply_operation(phi, op)

if op.grad_method:
if param_number in tape.trainable_params:
mu = self._apply_unitary(phi, d_op_matrix, op.wires)

jac_column = np.array(
[2 * dot_product_real(lambda_, mu) for lambda_ in lambdas]
)
jac[:, trainable_param_number] = jac_column
trainable_param_number -= 1
param_number -= 1

lambdas = [self._apply_operation(lambda_, op) for lambda_ in lambdas]
op.inv()

return jac


def operation_derivative(operation) -> np.ndarray:
trbromley marked this conversation as resolved.
Show resolved Hide resolved
r"""Calculate the derivative of an operation.

For an operation :math:`e^{i \hat{H} \phi t}`, this function returns the matrix representation
in the standard basis of its derivative with respect to :math:`t`, i.e.,

.. math:: \frac{d \, e^{i \hat{H} phi t}}{dt} = i \phi \hat{H} e^{i \hat{H} phi t}.

Args:
operation (qml.Operation): The operation to be differentiated.

Returns:
np.ndarray: the derivative of the operation as a matrix in the standard basis

Raises:
ValueError: if the operation does not have a generator or is not composed of a single
trainable parameter
"""
generator, prefactor = operation.generator

if generator is None:
raise ValueError(f"Operation {operation.name} does not have a generator")
if operation.num_params != 1:
# Note, this case should already be caught by the previous raise since we haven't worked out
# how to have an operator for multiple parameters. It is added here in case of a future
# change
raise ValueError(
f"Operation {operation.name} is not written in terms of a single parameter"
)

if not isinstance(generator, np.ndarray):
generator = generator.matrix

if operation.inverse:
prefactor *= -1
generator = generator.conj().T

return 1j * prefactor * generator @ operation.matrix
106 changes: 84 additions & 22 deletions pennylane/tape/qnode.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,12 @@ class QNode:
Only allowed on (simulator) devices with the "reversible" capability,
for example :class:`default.qubit <~.DefaultQubit>`.

* ``"adjoint"``: Uses an adjoint `method <https://arxiv.org/abs/2009.02823>`__ that
trbromley marked this conversation as resolved.
Show resolved Hide resolved
reverses through the circuit after a forward pass by iteratively applying the inverse
(adjoint) gate. This method is similar to the reversible method, but has a lower time
overhead and a similar memory overhead. Only allowed on simulator devices such as
:class:`default.qubit <~.DefaultQubit>`.

* ``"parameter-shift"``: Use the analytic parameter-shift
rule for all supported quantum operation arguments, with finite-difference
as a fallback.
Expand Down Expand Up @@ -148,15 +154,13 @@ def __init__(self, func, device, interface="autograd", diff_method="best", **dif
# store the user-specified differentiation method
self.diff_method = diff_method

self._tape, self.interface, diff_method, self.device = self.get_tape(
self._tape, self.interface, self.device, tape_diff_options = self.get_tape(
trbromley marked this conversation as resolved.
Show resolved Hide resolved
device, interface, diff_method
)

# The arguments to be passed to JacobianTape.jacobian
self.diff_options = diff_options or {}
# Store the differentiation method to be passed to JacobianTape.jacobian().
# Note that the tape accepts a different set of allowed methods than the QNode:
# best, analytic, numeric, device
self.diff_options["method"] = diff_method
self.diff_options.update(tape_diff_options)
trbromley marked this conversation as resolved.
Show resolved Hide resolved

self.dtype = np.float64
self.max_expansion = 2
Expand All @@ -170,7 +174,7 @@ def get_tape(device, interface, diff_method="best"):
device (.Device): PennyLane device
interface (str): name of the requested interface
diff_method (str): The requested method of differentiation. One of
``"best"``, ``"backprop"``, ``"reversible"``, ``"device"``,
``"best"``, ``"backprop"``, ``"reversible"``, ``"adjoint"``, ``"device"``,
``"parameter-shift"``, or ``"finite-diff"``.

Returns:
Expand All @@ -188,18 +192,26 @@ def get_tape(device, interface, diff_method="best"):
if diff_method == "reversible":
return QNode._validate_reversible_method(device, interface)

if diff_method == "adjoint":
return QNode._validate_adjoint_method(device, interface)

if diff_method == "device":
return QNode._validate_device_method(device, interface)

if diff_method == "parameter-shift":
return QNode._get_parameter_shift_tape(device), interface, "analytic", device
return (
QNode._get_parameter_shift_tape(device),
interface,
device,
{"method": "analytic"},
Copy link
Contributor

Choose a reason for hiding this comment

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

How come this became a dictionary?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It allows us to specify more than one option, for example, adjoint is: {"method": "device", "jacobian_method": "adjoint_jacobian"} while another device-based Jacobian would be {"method": "device"}. We could feasibly have other gradient methods that live on the device in future (if there's a reason to)

Copy link
Contributor

Choose a reason for hiding this comment

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

What's the difference between method and jacobian_method and why would both be needed?

Copy link
Contributor

Choose a reason for hiding this comment

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

Hmm, imho it was originally meant to describe that a device could have several device jacobians defined, so method would indicate that we'd like to use a jacobian defined by the device and then we could select the specific type with jacobian_method.

However, the question is still great, would we actually need a separate jacobian_method, or could we just go with:

  • method="device"
  • method="device_adjoint"?

Guess it comes down to how complex the use case is, i.e., would we expect to see (many) devices that are different in their default device diff method> If it turns out that the use cases are more complex, then it can be worth having these two entries.

)

if diff_method == "finite-diff":
return JacobianTape, interface, "numeric", device
return JacobianTape, interface, device, {"method": "numeric"}

raise qml.QuantumFunctionError(
f"Differentiation method {diff_method} not recognized. Allowed "
"options are ('best', 'parameter-shift', 'backprop', 'finite-diff', 'device', 'reversible')."
"options are ('best', 'parameter-shift', 'backprop', 'finite-diff', 'device', 'reversible', 'adjoint')."
)

@staticmethod
Expand Down Expand Up @@ -234,9 +246,14 @@ def get_best_method(device, interface):
return QNode._validate_backprop_method(device, interface)
trbromley marked this conversation as resolved.
Show resolved Hide resolved
except qml.QuantumFunctionError:
try:
return QNode._get_parameter_shift_tape(device), interface, "best", device
return (
trbromley marked this conversation as resolved.
Show resolved Hide resolved
QNode._get_parameter_shift_tape(device),
interface,
device,
{"method": "best"},
)
except qml.QuantumFunctionError:
return JacobianTape, interface, "numeric", device
return JacobianTape, interface, device, {"method": "numeric"}

@staticmethod
def _validate_backprop_method(device, interface):
Expand Down Expand Up @@ -271,7 +288,7 @@ def _validate_backprop_method(device, interface):
# device supports backpropagation natively

if interface == backprop_interface:
return JacobianTape, interface, "backprop", device
return JacobianTape, interface, device, {"method": "backprop"}

raise qml.QuantumFunctionError(
f"Device {device.short_name} only supports diff_method='backprop' when using the "
Expand All @@ -284,8 +301,13 @@ def _validate_backprop_method(device, interface):
if interface in backprop_devices:
# TODO: need a better way of passing existing device init options
# to a new device?
device = qml.device(backprop_devices[interface], wires=device.wires, analytic=True)
return JacobianTape, interface, "backprop", device
device = qml.device(
backprop_devices[interface],
wires=device.wires,
shots=device.shots,
analytic=True,
)
return JacobianTape, interface, device, {"method": "backprop"}

raise qml.QuantumFunctionError(
f"Device {device.short_name} only supports diff_method='backprop' when using the "
Expand Down Expand Up @@ -323,7 +345,41 @@ def _validate_reversible_method(device, interface):
f"The {device.short_name} device does not support reversible differentiation."
)

return ReversibleTape, interface, "analytic", device
return ReversibleTape, interface, device, {"method": "analytic"}

@staticmethod
def _validate_adjoint_method(device, interface):
"""Validates whether a particular device and JacobianTape interface
supports the ``"adjoint"`` differentiation method.

Args:
device (.Device): PennyLane device
interface (str): name of the requested interface

Returns:
tuple[.JacobianTape, str, str]: tuple containing the compatible
JacobianTape, the interface to apply, and the method argument
to pass to the ``JacobianTape.jacobian`` method

Raises:
qml.QuantumFunctionError: if the device does not support adjoint backprop
"""
supported_device = hasattr(device, "_apply_operation")
supported_device = supported_device and hasattr(device, "_apply_unitary")
supported_device = supported_device and device.capabilities().get("returns_state")
supported_device = supported_device and hasattr(device, "adjoint_jacobian")
trbromley marked this conversation as resolved.
Show resolved Hide resolved

if not supported_device:
raise ValueError(
f"The {device.short_name} device does not support adjoint differentiation."
)

return (
JacobianTape,
interface,
device,
{"method": "device", "jacobian_method": "adjoint_jacobian"},
)

@staticmethod
def _validate_device_method(device, interface):
Expand Down Expand Up @@ -352,7 +408,7 @@ def _validate_device_method(device, interface):
"method for computing the jacobian."
)

return JacobianTape, interface, "device", device
return JacobianTape, interface, device, {"method": "device"}

@staticmethod
def _get_parameter_shift_tape(device):
Expand Down Expand Up @@ -591,12 +647,12 @@ def to_tf(self, dtype=None):

if self.interface != "tf" and self.interface is not None:
# Since the interface is changing, need to re-validate the tape class.
self._tape, interface, diff_method, self.device = self.get_tape(
self._tape, interface, self.device, diff_options = self.get_tape(
self._original_device, "tf", self.diff_method
)

self.interface = interface
self.diff_options["method"] = diff_method
self.diff_options.update(diff_options)
else:
self.interface = "tf"

Expand Down Expand Up @@ -631,12 +687,12 @@ def to_torch(self, dtype=None):

if self.interface != "torch" and self.interface is not None:
# Since the interface is changing, need to re-validate the tape class.
self._tape, interface, diff_method, self.device = self.get_tape(
self._tape, interface, self.device, diff_options = self.get_tape(
self._original_device, "torch", self.diff_method
)

self.interface = interface
self.diff_options["method"] = diff_method
self.diff_options.update(diff_options)
else:
self.interface = "torch"

Expand All @@ -663,12 +719,12 @@ def to_autograd(self):

if self.interface != "autograd" and self.interface is not None:
# Since the interface is changing, need to re-validate the tape class.
self._tape, interface, diff_method, self.device = self.get_tape(
self._tape, interface, self.device, diff_options = self.get_tape(
self._original_device, "autograd", self.diff_method
)

self.interface = interface
self.diff_options["method"] = diff_method
self.diff_options.update(diff_options)
else:
self.interface = "autograd"

Expand Down Expand Up @@ -757,6 +813,12 @@ def qnode(device, interface="autograd", diff_method="best", **diff_options):
Only allowed on (simulator) devices with the "reversible" capability,
for example :class:`default.qubit <~.DefaultQubit>`.

* ``"adjoint"``: Uses an adjoint `method <https://arxiv.org/abs/2009.02823>`__ that
reverses through the circuit after a forward pass by iteratively applying the inverse
(adjoint) gate. This method is similar to the reversible method, but has a lower time
overhead and a similar memory overhead. Only allowed on simulator devices such as
:class:`default.qubit <~.DefaultQubit>`.

* ``"device"``: Queries the device directly for the gradient.
Only allowed on devices that provide their own gradient rules.

Expand Down
5 changes: 3 additions & 2 deletions pennylane/tape/tapes/jacobian_tape.py
Original file line number Diff line number Diff line change
Expand Up @@ -332,7 +332,8 @@ def device_pd(self, device, params=None, **options):
params (list[Any]): The quantum tape operation parameters. If not provided,
the current tape parameter values are used (via :meth:`~.get_parameters`).
"""
# pylint:disable=unused-argument
jacobian_method = getattr(device, options.get("jacobian_method", "jacobian"))

if params is None:
params = np.array(self.get_parameters())

Expand All @@ -343,7 +344,7 @@ def device_pd(self, device, params=None, **options):

# TODO: modify devices that have device Jacobian methods to
# accept the quantum tape as an argument
jac = device.jacobian(self)
jac = jacobian_method(self)

# restore original parameters
self.set_parameters(saved_parameters)
Expand Down
Loading