-
Notifications
You must be signed in to change notification settings - Fork 575
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 convenience decorators for creating qfunc transforms #1315
Conversation
Hello. You may have forgotten to update the changelog!
|
Codecov Report
@@ Coverage Diff @@
## master #1315 +/- ##
=======================================
Coverage 98.16% 98.17%
=======================================
Files 154 155 +1
Lines 11559 11620 +61
=======================================
+ Hits 11347 11408 +61
Misses 212 212
Continue to review full report at Codecov.
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@josh146 this is going to be great! 🎉 Have left some comments after just a first read; I will spend some time trying to use the new features, and then update with more feedback.
pennylane/transforms/decorators.py
Outdated
qml.RX(0.1, wires=0) | ||
|
||
# apply the transform to the ansatz | ||
my_transform(*transform_weights)(ansatz)(param) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh this is really cool! And I think this clears up my not understanding how ctrl
and adjoint
fit in this category; it looks like my_transform
is being used here in a similar way?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
yep! exactly 🙂 Both adjoint
and ctrl
don't have any 'transform parameters', so they simply look like:
qml.adjoint(ansatz)(param)
But since this one has additional (trainable) transform parameters, you have that extra level of nesting.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh actually, I think this is an issue. I've coded this up for the most general case, which means no transform parameters would look like this:
my_transform()(ansatz)(param)
🙁
I'm not 100% sure it's possible to have a decorator that supports both cases (transform params + no transform params).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So, this is actually a Python limitation as far as I can tell; you can either create a decorator that has parameters, or does not, you can't have it change dynamically.
So we have several options:
- It always permits parameters, which results in the ugly
my_transform()(ansatz)(params)
if your transform has no parameters. - Alternatively, we could add another level of nesting to the decorator, and use
inspect.signature
to determine the decorator wrapping. This should work, but haven't tested it. - We could add a parameter to the decorator itself:
@qml.qfunc_transform(params=False)
? But the onus is now on the user. - Finally, we could have two separate decorators,
@qml.qfunc_transform_params
and@qml.qfunc_transform_no_params
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think I just ran into this very problem while working with a tape/qfunc transform that didn't have any parameters. I had to construct the decorator like @transform()
to get a QNode I used it in to actually run. If we were to add another level of nesting, as you suggest, that would make both cases possible without the user having to worry about it? (From a user standpoint I feel like that's the best option, if it works)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@glassnotes I implemented option (2) above using inspect.signature
, it works really nicely :)
@qml.qfunc_transform
@qml.single_tape_transform
def convert_cnots(tape):
"""qfunc transform with no arguments"""
for op in tape.operations + tape.measurements:
if op.name == "CNOT":
wires = op.wires
qml.Hadamard(wires=wires[1])
qml.CZ(wires=[wires[1], wires[0]])
qml.Hadamard(wires=wires[1])
else:
op.queue()
@qml.qfunc_transform
@qml.single_tape_transform
def expand_hadamards(tape, x, y):
"""qfunc transform with arguments"""
for op in tape.operations + tape.measurements:
if op.name == "Hadamard":
qml.RZ(x, wires=op.wires)
qml.RY(y, wires=op.wires)
else:
op.queue()
dev = qml.device("default.qubit", wires=3)
@expand_hadamards(0.5, 0.9) # pass arguments here
@convert_cnots # no arguments, so no parenthesis
def ansatz():
qml.CNOT(wires=[0, 1])
qml.CNOT(wires=[1, 2])
qml.CNOT(wires=[2, 0])
Or, alternatively:
def ansatz():
qml.CNOT(wires=[0, 1])
qml.CNOT(wires=[1, 2])
qml.CNOT(wires=[2, 0])
transformed_ansatz = expand_hadamards(0.5, 0.1)(convert_cnots(ansatz))
@qml.qnode(dev)
@expand_hadamards(1., 2.)
def my_other_circuit(x):
qml.RX(x, wires=0)
transformed_ansatz()
qml.Hadamard(wires=2)
return qml.expval(qml.PauliZ(0))
If you could test it out and see if it fixes it on your end, that would be great!
pennylane/transforms/decorators.py
Outdated
if not isinstance(tape_transform, single_tape_transform): | ||
raise ValueError("Can only convert single tape transforms into qfunc transforms!") | ||
|
||
@functools.wraps(tape_transform) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I know this isn't front-facing or anything but is it necessary to have so many layers of wrappers? What is each of them doing?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The four levels of wrapping aligns with the four sets of arguments we can provide at different points within the process.
- The first is the tape transform we want to convert,
- the second is the parameters of the tape transform,
- the third is the qfunc we are transforming, and
- the fourth are the transformed qfunc arguments :)
pennylane/transforms/decorators.py
Outdated
|
||
def tape_transform(tape, *args, **kwargs): | ||
... | ||
return tapes, processing_fn |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The returned values here conflict with the new definition of tape_transform
, since those are only supposed to return only a single tape. Is the idea that this is something that could wrap one of those, but return possibly more tapes (and the function)?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yep. Essentially:
-
qfunc transforms and single tape transforms are, to all intense and purposes, identical. So if you are doing something like compilation (tape->tape), it should be a qfunc transform.
-
The only reason to go further and do a QNode transform is:
- You need to do something more powerful, as in the case here where the transform is tape->tapes with post-processing.
- You need access to more information, such as the QNode device.
In fact, there are downsides to creating a QNode transform with just a tape->tape transform: you lose things like composability, and the ability to apply the transform to sub-circuits within a QNode. E.g., you might have a QNode that contains several templates, and you want to compile each template differently -- this is possible with a qfunc transform.
So... yeah. This was deliberate to an extent, so the documentation should better communicate this 🤔
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh okay, gotcha! Yeah we should definitely emphasize that 👍
pennylane/transforms/decorators.py
Outdated
|
||
@qml.qnode_transform | ||
def my_transform(tape, x, y): | ||
tape1 = tape_transform(tape, x) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's actually so cool how you can have different parameters for the transforms. I imagine this could be used, e.g., to specify different bases to unroll in, or any special gate decompositions that would be used to override existing ones. 🎉
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah! Also, I'm quietly excited for transforms that have continuous/differentiable parameters. Do any compilation routines come to mind? Most I know only take discrete parameters
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Don't know of any offhand, this could be interesting to investigate! 😁
Co-authored-by: Olivia Di Matteo <2068515+glassnotes@users.noreply.github.com>
Co-authored-by: Olivia Di Matteo <2068515+glassnotes@users.noreply.github.com>
…into better-transforms
Co-authored-by: Olivia Di Matteo <2068515+glassnotes@users.noreply.github.com>
Co-authored-by: Olivia Di Matteo <2068515+glassnotes@users.noreply.github.com>
Co-authored-by: Olivia Di Matteo <2068515+glassnotes@users.noreply.github.com>
.github/CHANGELOG.md
Outdated
qml.RX(0.1, wires=0) | ||
|
||
# apply the transform to the ansatz | ||
my_transform(*transform_weights)(ansatz)(param) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a very confusing example, or maybe just confusing syntax. Why are we transforming the first of the two parameters? I would expect something like my_transform(ansatz)(transform_wieghts, param)
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree in that it does come a bit out of left-field 😆
Funnily enough, my intuition prior to working on this PR was slightly different - I was expecting something along the lines of
my_transform(ansatz, transform_weights)(param)
(in that the transform_weights
are a dependent quantity when applying the transform).
The reason instead for
my_transform(transform_weights)(ansatz)(param)
Is due almost completely to how decorators work in Python; decorators must always be of the form callable -> callable
, which causes the above 'restriction'. However, it is also slightly more flexible - you can break it up into the following 'steps':
# 1. create a transform defined by `transform_weights`
specific_transform = my_transform(transform_weights)
# 2. Apply the transform (callable -> callable) to the qfunc
new_qfunc = specific_transform(new_qfunc)
# 3. evaluate the new, transformed, quantum function
new_qfunc(params)
If we decide to drop the decorator functionality, then we could definitely support
my_transform(ansatz, transform_weights)(param)
@glassnotes, what do you think?
- Drop the decorator functionality in favour of a clearer 'inline' syntax?
- Keep the decorator functionality at the expense of a slightly more complicated 'inline' syntax?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm in favour of the decorators because of how much they abstract away in terms of writing the actual transform. However we should definitely add the example you wrote above to the docs, to show the equivalence of the inline version vs. "expanded" version.
`CRX` gates with a sequence of `RX`, `RY`, and `CZ` gates: | ||
|
||
```python | ||
@qml.qfunc_transform |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why does it take a tape
if it's a "qfunc" transform?
I thought "qfunc" transforms took in quantum functions and returned quantum functions?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@albi3ro that's exactly right! You could always write a qfunc transform manually like so, without ever needing to use any decorators:
def transform(old_qfunc, params):
def new_qfunc(*args, **kwargs):
# 1. extract the tape from the old qfunc, being
# careful *not* to have it queued.
tape = make_tape(old_qfunc)(*args, **kwargs)
# 2. transform the tape
new_tape = tape_transform(tape, params)
# 3. queue the *new* tape to the active queuing context
new_tape.queue()
return new_qfunc
Note that this is pseudocode - steps (1) and (3) are much more complicated in practice than they are presented here! It's actually quite non-trivial to get the above working in general, because it requires:
-
an intimate knowledge of the queuing system and how it works
-
how to convert qfuncs into tapes without them being queued
-
dealing with potentially nested queuing contexts
-
having to take into account that qfuncs can be called in lots of different places
Steps (1) and (3) are actually the same no matter the qfunc transform; really, it is only step (2), tape_transform
and the corresponding tape transform parameters, that really define the qfunc transformation.
So the idea with the decorator is therefore:
- Allow a dev to provide a tape transform that defines the intended qfunc transform
- All the queuing boilerplate (steps 1 and 3) are added automatically by the decorator (and are also tested thoroughly by the decorator tests, reducing maintenance overhead and chances of bugs!)
In essence, you can think of the decorator as taking a tape_transform
, and elevating it into a qfunc transform.
@glassnotes @albi3ro I pushed a commit, 3add7a9, which rewrites the |
Awesome, I left a couple comments regarding order/presentation of the explanations, but the explanations themselves are clear 🚀 |
Thanks! have addressed them in 21b779a :) |
pennylane/transforms/__init__.py
Outdated
Quantum function transforms | ||
--------------------------- |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Quantum function transforms | |
--------------------------- |
Would it make sense, while things are in flux, to just remove the headers that name the type of transforms, and keep instead just the one-line description that explains how they work? This keeps transforms with similar behaviour grouped, but doesn't bind us to a specific name yet.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done!
qml.queuing.QueuingContext.remove(self) | ||
|
||
|
||
class single_tape_transform: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I did not notice this before, but why is this a class instead of a function like qfunc_transform
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
purely so that when we need to, we can do isinstance(fn, single_tape_transform)
😆 So it's an implementation detail
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Left a few more notes, but it made more sense looking at it this time around.
import pennylane as qml | ||
|
||
|
||
def make_tape(fn): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Will this function be useful outside of this file? Or should we name it _make_tape
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In the last couple of weeks, I've actually noticed a lot of places in the core codebase where having access to make_tape
would be super useful. For example, there are a lot of places currently doing:
active_tape = get_active_tape()
if active_tape is None:
with QuantumTape() as tape:
fn(*args, **kwargs)
else:
with active_tape.stop_recording(), QuantumTape() as tape:
fn(*args, **kwargs)
# do something with tape
So it would be a huge benefit to maintainability and bug squashing to simply replace these occurrences with
tape = make_tape(fn)(*args, **kwargs)
Hence why I didn't make this private 😆
|
||
>>> my_transform(transform_weights)(ansatz)(param) | ||
|
||
simply 'chains' these three steps together, into a single call. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍 Nice explanation here. Clears a lot of confusion up for me.
|
||
return wrapper | ||
|
||
elif len(params) == 1: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why not else
?
Should you raise an error if len(params)==0
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think len(params)
can be zero here; this invalid edge case would have already been caught by single_tape_transform(tape_transform)
Co-authored-by: Christina Lee <christina@xanadu.ai>
…into better-transforms
@albi3ro, great suggestions! I've just updated the example to use your scaling of rotation parameters example. |
Context: Currently, adding transforms to the code base requires a deep understanding of the PennyLane queuing system, and a lot of boilerplate.
Description of the Change:
Adds two decorators to help abstract away the queuing boilerplate when creating quantum function transforms, and lesson the overhead for users and developers.
The two decorators are:
Single tape transform
@qml.single_tape_transform
:tape -> tape
.Rendered docs and code examples available here.
Quantum function (qfunc) transform
@qml.qfunc_transform
:qfunc -> qfunc
.qml.adjoint()
,qml.ctrl()
.Rendered docs and code examples available here.
Benefits:
Makes it much easier to build differentiable quantum transforms!
The decorators are optional, and not required; you can still build transforms manually if you wish.
While these are presented first and foremost as decorators, they can always be used 'inline', like all Python decorators.
Possible Drawbacks:
Related GitHub Issues: n/a