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 convenience decorators for creating qfunc transforms #1315

Merged
merged 54 commits into from
Jun 3, 2021

Conversation

josh146
Copy link
Member

@josh146 josh146 commented May 14, 2021

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:

    • Maps tape -> tape.
    • Fully composable.
    • Classical processing of gate arguments is allowed.
    • Usually used as a unit building blocks for qfunc and QNode transforms.

    Rendered docs and code examples available here.

  • Quantum function (qfunc) transform @qml.qfunc_transform:

    • Maps qfunc -> qfunc.
    • Fully composable.
    • Simply the functional version of the single tape transform.
    • Examples already in the code base include 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:

  • Since these can be used as both decorators and inline, and there are cases where one makes more sense than the other, the documentation might be a bit confusing on first read!

Related GitHub Issues: n/a

@github-actions
Copy link
Contributor

Hello. You may have forgotten to update the changelog!
Please edit .github/CHANGELOG.md with:

  • A one-to-two sentence description of the change. You may include a small working example for new features.
  • A link back to this PR.
  • Your name (or GitHub username) in the contributors section.

@josh146 josh146 changed the title Add convenience decorators for creating transforms [WIP] Add convenience decorators for creating transforms May 14, 2021
@josh146 josh146 added the WIP 🚧 Work-in-progress label May 14, 2021
@codecov
Copy link

codecov bot commented May 14, 2021

Codecov Report

Merging #1315 (8afc1b3) into master (7b25e3e) will increase coverage by 0.00%.
The diff coverage is 100.00%.

Impacted file tree graph

@@           Coverage Diff           @@
##           master    #1315   +/-   ##
=======================================
  Coverage   98.16%   98.17%           
=======================================
  Files         154      155    +1     
  Lines       11559    11620   +61     
=======================================
+ Hits        11347    11408   +61     
  Misses        212      212           
Impacted Files Coverage Δ
pennylane/__init__.py 98.57% <ø> (ø)
pennylane/transforms/__init__.py 100.00% <100.00%> (ø)
pennylane/transforms/qfunc_transforms.py 100.00% <100.00%> (ø)

Continue to review full report at Codecov.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 7b25e3e...8afc1b3. Read the comment docs.

Copy link
Contributor

@glassnotes glassnotes left a 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 Show resolved Hide resolved
pennylane/transforms/decorators.py Outdated Show resolved Hide resolved
pennylane/transforms/decorators.py Outdated Show resolved Hide resolved
pennylane/transforms/decorators.py Outdated Show resolved Hide resolved
qml.RX(0.1, wires=0)

# apply the transform to the ansatz
my_transform(*transform_weights)(ansatz)(param)
Copy link
Contributor

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?

Copy link
Member Author

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.

Copy link
Member Author

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

Copy link
Member Author

@josh146 josh146 May 14, 2021

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.

Copy link
Contributor

@glassnotes glassnotes May 14, 2021

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)

Copy link
Member Author

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!

if not isinstance(tape_transform, single_tape_transform):
raise ValueError("Can only convert single tape transforms into qfunc transforms!")

@functools.wraps(tape_transform)
Copy link
Contributor

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?

Copy link
Member Author

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.

  1. The first is the tape transform we want to convert,
  2. the second is the parameters of the tape transform,
  3. the third is the qfunc we are transforming, and
  4. the fourth are the transformed qfunc arguments :)


def tape_transform(tape, *args, **kwargs):
...
return tapes, processing_fn
Copy link
Contributor

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

Copy link
Member Author

@josh146 josh146 May 14, 2021

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 🤔

Copy link
Contributor

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 Show resolved Hide resolved

@qml.qnode_transform
def my_transform(tape, x, y):
tape1 = tape_transform(tape, x)
Copy link
Contributor

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. 🎉

Copy link
Member Author

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

Copy link
Contributor

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! 😁

pennylane/transforms/decorators.py Outdated Show resolved Hide resolved
josh146 and others added 6 commits May 15, 2021 01:38
Co-authored-by: Olivia Di Matteo <2068515+glassnotes@users.noreply.github.com>
Co-authored-by: Olivia Di Matteo <2068515+glassnotes@users.noreply.github.com>
josh146 and others added 4 commits May 17, 2021 21:41
josh146 and others added 4 commits May 18, 2021 00:25
@josh146 josh146 changed the title [WIP] Add convenience decorators for creating transforms Add convenience decorators for creating transforms May 17, 2021
josh146 and others added 2 commits May 26, 2021 23:42
Co-authored-by: Olivia Di Matteo <2068515+glassnotes@users.noreply.github.com>
@josh146 josh146 requested a review from glassnotes May 28, 2021 15:13
qml.RX(0.1, wires=0)

# apply the transform to the ansatz
my_transform(*transform_weights)(ansatz)(param)
Copy link
Contributor

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

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

Copy link
Contributor

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
Copy link
Contributor

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?

Copy link
Member Author

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.

@josh146
Copy link
Member Author

josh146 commented Jun 2, 2021

@glassnotes @albi3ro I pushed a commit, 3add7a9, which rewrites the qfunc_transform decorator to better address the points raised above; let me know if this is clearer now!

@josh146 josh146 requested a review from albi3ro June 2, 2021 09:19
@glassnotes
Copy link
Contributor

@glassnotes @albi3ro I pushed a commit, 3add7a9, which rewrites the qfunc_transform decorator to better address the points raised above; let me know if this is clearer now!

Awesome, I left a couple comments regarding order/presentation of the explanations, but the explanations themselves are clear 🚀

@josh146
Copy link
Member Author

josh146 commented Jun 2, 2021

Thanks! have addressed them in 21b779a :)

Comment on lines 33 to 34
Quantum function transforms
---------------------------
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
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.

Copy link
Member Author

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:
Copy link
Contributor

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?

Copy link
Member Author

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

Copy link
Contributor

@albi3ro albi3ro left a 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.

pennylane/transforms/__init__.py Outdated Show resolved Hide resolved
import pennylane as qml


def make_tape(fn):
Copy link
Contributor

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?

Copy link
Member Author

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 😆

.github/CHANGELOG.md Show resolved Hide resolved

>>> my_transform(transform_weights)(ansatz)(param)

simply 'chains' these three steps together, into a single call.
Copy link
Contributor

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.

tests/transforms/test_qfunc_transform.py Outdated Show resolved Hide resolved

return wrapper

elif len(params) == 1:
Copy link
Contributor

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?

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 len(params) can be zero here; this invalid edge case would have already been caught by single_tape_transform(tape_transform)

@josh146
Copy link
Member Author

josh146 commented Jun 3, 2021

@albi3ro, great suggestions! I've just updated the example to use your scaling of rotation parameters example.

@josh146 josh146 requested a review from albi3ro June 3, 2021 08:16
@josh146 josh146 merged commit 126ecc9 into master Jun 3, 2021
@josh146 josh146 deleted the better-transforms branch June 3, 2021 13:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
review-ready 👌 PRs which are ready for review by someone from the core team.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

4 participants