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

[OpRefactor] Make decomposition a method of the main Operator #1873

Merged

Conversation

glassnotes
Copy link
Contributor

@glassnotes glassnotes commented Nov 8, 2021

Context: Operator overhaul.

Description of the Change: The static decomposition has been moved to the Operator class, and a new method decompose has been added.

Benefits: Decompositions can now be obtained from specific instances of operations, and can use their stored parameters and wires. The move to Operator will allow the decomposition to be used as an arithmetic operation when the Operator class is overhauled.

Possible Drawbacks: Josh has found some issues with the decompose method in the cases where some of the non-trainable parameters are required for constructing the decomposition.

Related GitHub Issues: N/A

qml.OrbitalRotation.decomposition(weights[layer][idx][1], wires=qwires[idx])

with qml.tape.stop_recording():
op1 = qml.OrbitalRotation(include_pi_param, wires=qwires[idx])
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is super ugly. Is there any way around this?

Copy link
Contributor

Choose a reason for hiding this comment

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

I fear this is a very typical example for the weaknesses of the queuing system, and it does not only appear here, but is a general pattern! Every time we create an operation to call its method which creates other operations (i.e. to simplify an already created hamiltonian) this happens.

But the alternative, to keep all methods static, is not a scalable option I fear.

I hope we can solve this elsewhere.

@codecov
Copy link

codecov bot commented Nov 8, 2021

Codecov Report

Merging #1873 (713d4ec) into master (91479c6) will increase coverage by 0.00%.
The diff coverage is 100.00%.

Impacted file tree graph

@@           Coverage Diff           @@
##           master    #1873   +/-   ##
=======================================
  Coverage   98.98%   98.98%           
=======================================
  Files         219      219           
  Lines       16509    16513    +4     
=======================================
+ Hits        16341    16345    +4     
  Misses        168      168           
Impacted Files Coverage Δ
pennylane/ops/qubit/attributes.py 100.00% <ø> (ø)
pennylane/_qubit_device.py 98.69% <100.00%> (ø)
pennylane/operation.py 98.88% <100.00%> (+<0.01%) ⬆️
pennylane/ops/qubit/matrix_ops.py 99.14% <100.00%> (ø)
pennylane/tape/reversible.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 91479c6...713d4ec. Read the comment docs.

@glassnotes glassnotes changed the title [WIP] Make decomposition a method of the main operator Make decomposition a method of the main Operator Nov 8, 2021
@glassnotes glassnotes marked this pull request as ready for review November 8, 2021 18:43
Copy link
Contributor

@mariaschuld mariaschuld left a comment

Choose a reason for hiding this comment

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

Fantastic, thanks for tackling this.

Does it feel right? I think it is much better to use the internal information in decomposition instead of arguments, and the only disadvantage is a structural problem of queuing, so I am inclined to ignore it for now and rather make a better queueing system.

@@ -920,7 +920,7 @@ def adjoint_jacobian(self, tape, starting_state=None, use_device_state=False):
for op in reversed(tape.operations):
if op.num_params > 1:
if isinstance(op, qml.Rot) and not op.inverse:
ops = op.decomposition(*op.parameters, wires=op.wires)
ops = op.decomposition()
Copy link
Contributor

Choose a reason for hiding this comment

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

I like this a lot!

pennylane/operation.py Outdated Show resolved Hide resolved
pennylane/ops/qubit/matrix_ops.py Show resolved Hide resolved
def decomposition(D, wires):
return [QubitUnitary(qml.math.diag(D), wires=wires)]
def decomposition(self):
return [QubitUnitary(qml.math.diag(self.parameters[0]), wires=self.wires)]
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 pattern that the developer needs to know that parameters[0] is D is not optimal, but I can't think of a better option.

Copy link
Contributor

Choose a reason for hiding this comment

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

On the other hand, if PennyLane feeds op.parameters into decomposition then PennyLane makes the assumption that D is parameters[0], which is probably a lot less safe!

qml.OrbitalRotation.decomposition(weights[layer][idx][1], wires=qwires[idx])

with qml.tape.stop_recording():
op1 = qml.OrbitalRotation(include_pi_param, wires=qwires[idx])
Copy link
Contributor

Choose a reason for hiding this comment

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

I fear this is a very typical example for the weaknesses of the queuing system, and it does not only appear here, but is a general pattern! Every time we create an operation to call its method which creates other operations (i.e. to simplify an already created hamiltonian) this happens.

But the alternative, to keep all methods static, is not a scalable option I fear.

I hope we can solve this elsewhere.

@josh146
Copy link
Member

josh146 commented Nov 9, 2021

I must admit, I can't stop thinking about this 🤔

But the alternative, to keep all methods static, is not a scalable option I fear.

Would it make sense to keep decompositions as a static method (same for terms, and matrix), but simply enforce that it takes the same arguments as __init__? I feel like this might solve the problem.

E.g.,

class MyOp(Operation):
    def __init__(self, H, x, t, wires):
        super().__init__(H.coeffs, x, t, wires)
        self.hamiltonian = H

    @Operation.decomposition()
    def decomposition(self, H, x, t, wires):
        return [qml.ApproxTimeEvolution(H, t, wires), qml.RX(x, wires[0])]

where @Operation.decomposition() is just some 'registration' function.

So you could then do either:

>>> MyOp.decomposition(H, x, t, wires=[0, 1])

or

>>> MyOp(H, x, t, wires=[0, 1]).decomposition()

The ambiguity is also removed, since neither the user nor the base class makes any 'assumptions' about the signature of decomposition --- it always just matches the __init__.

Co-authored-by: Maria Schuld <mariaschuld@gmail.com>
@mariaschuld mariaschuld self-requested a review November 10, 2021 11:09
with qml.tape.stop_recording():
op = qml.CRX(x, wires=[0, 1])
op.decomposition()
```
Copy link
Contributor

Choose a reason for hiding this comment

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

Is this still valid, since it's only relevant for tests and there one can do _decomposition?

Copy link
Contributor

@mariaschuld mariaschuld left a comment

Choose a reason for hiding this comment

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

Perfect!

It may feel weird for now, but I up once we have all bits tidied up in a systematic manner the benefit will become obvious!

doc/releases/changelog-dev.md Outdated Show resolved Hide resolved
@mariaschuld
Copy link
Contributor

Oh, only consider my suggestion for the changelog, I think that would need to be updated?

@glassnotes
Copy link
Contributor Author

Oh, only consider my suggestion for the changelog, I think that would need to be updated?

My bad, forgot to update that! Thanks :)

Comment on lines 553 to 555
if self.num_params == 0:
return self._decomposition(wires=self.wires)
return self._decomposition(*self.parameters, wires=self.wires)
Copy link
Member

Choose a reason for hiding this comment

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

I have one worry here, but I'm not sure of the solution. Given that we are planning on making it so that only trainable parameters are stored in self.parameters, and devs should instead store non-trainable parameters as static attributes... how will we pass non-trainable parameters here?

@mariaschuld, any ideas? I am drawing blanks.

For example, consider the following:

class MyOp(Operation):

    def __init__(self, H, t, boolean_arg=True, wires=None):
        # store trainable parameters
        super().__init__(H.coeffs, t, wires=None)

        # after calling __init__, trainable parameters
        # are now stored as self.parameters
        assert self.parameters == H.coeffs, t

        # store non-trainable parameters as an attribute
        self.boolean_arg = boolean_arg
        self.H = H

    @staticmethod
    def _decomposition(H, t, boolean_arg=True, wires=None):
        # the parent class will attempt to pass H.coeffs and t,
        # not H and boolean_arg!

Copy link
Member

@josh146 josh146 Nov 11, 2021

Choose a reason for hiding this comment

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

The only solution I can think of, is something along the lines of what PyTorch does (https://pytorch.org/docs/stable/notes/extending.html):

# Inherit from Function
class LinearFunction(Function):

    # Note that both forward and backward are @staticmethods
    @staticmethod
    # bias is an optional argument
    def forward(ctx, input, weight, bias=None):
        ctx.save_for_backward(input, weight, bias)
        output = input.mm(weight.t())
        if bias is not None:
            output += bias.unsqueeze(0).expand_as(output)
        return output

    # This function has only a single output, so it gets only one gradient
    @staticmethod
    def backward(ctx, grad_output):
        # This is a pattern that is very convenient - at the top of backward
        # unpack saved_tensors and initialize all gradients w.r.t. inputs to
        # None. Thanks to the fact that additional trailing Nones are
        # ignored, the return statement is simple even when the function has
        # optional inputs.
        input, weight, bias = ctx.saved_tensors
        grad_input = grad_weight = grad_bias = None

        # These needs_input_grad checks are optional and there only to
        # improve efficiency. If you want to make your code simpler, you can
        # skip them. Returning gradients for inputs that don't require it is
        # not an error.
        if ctx.needs_input_grad[0]:
            grad_input = grad_output.mm(weight)
        if ctx.needs_input_grad[1]:
            grad_weight = grad_output.t().mm(input)
        if bias is not None and ctx.needs_input_grad[2]:
            grad_bias = grad_output.sum(0)

        return grad_input, grad_weight, grad_bias

When defining custom PyTorch operations, you create a class with static methods.

However, the first argument of each static method is always ctx. This is a 'context' object that

  1. provides helper methods, and
  2. allows the developers to store anything

Copy link
Member

@josh146 josh146 Nov 11, 2021

Choose a reason for hiding this comment

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

If we take a similar approach ourselves, the operator class could look like this:

class MyOp(Operation):

    @staticmethod
    def create(ctx, H, t, boolean_arg=True, wires=None):
        # store trainable parameters
        ctx.save_parameters(H.coeffs, t)

        # store non-trainable parameters as an attribute
        self.ctx.boolean_arg = boolean_arg
        self.ctx.H = H

    @staticmethod
    def _decomposition(*parameters, wires=None, ctx=None):
        H.coeffs, t = parameters
        H = ctx.Hamiltonian
        boolean_arg = ctx.boolean_arg

Copy link
Member

@josh146 josh146 left a comment

Choose a reason for hiding this comment

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

Forgot to pick this when I left my review :(

Copy link
Member

@josh146 josh146 left a comment

Choose a reason for hiding this comment

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

Looks good @glassnotes!

@@ -13,13 +13,27 @@

<h3>Improvements</h3>

* A `decompose()` method has been added to the `Operator` class such that we can
obtain (and queue) decompositions directly from instances of operations.
[(#1873)](https://github.com/PennyLaneAI/pennylane/pull/1873)
Copy link
Contributor

Choose a reason for hiding this comment

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

🚀

* ``qml.circuit_drawer.draw_mpl`` produces a matplotlib figure and axes given a tape.
[(#1787)](https://github.com/PennyLaneAI/pennylane/pull/1787)

* AngleEmbedding now supports `batch_params` decorator. [(#1812)](https://github.com/PennyLaneAI/pennylane/pull/1812)

<h3>Breaking changes</h3>

* The static method `decomposition()`, formerly in the `Operation` class, has
been moved to the base `Operator` class.
[(#1873)](https://github.com/PennyLaneAI/pennylane/pull/1873)
Copy link
Contributor

Choose a reason for hiding this comment

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

🚀

Copy link
Contributor

@mariaschuld mariaschuld left a comment

Choose a reason for hiding this comment

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

Nice and simple!

@mariaschuld mariaschuld self-requested a review November 15, 2021 09:28
Copy link
Contributor

@mariaschuld mariaschuld left a comment

Choose a reason for hiding this comment

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

I turned "approve" to "request changes" due to a minor codecov issue. I think since we now test decomposition instead of decompose there is no test where an int is fed as a wire. Maybe a test like this should help:

def test_decomposition_with_int_wire()
    op1 = qml.Hadamard.decomposition(1)
    op2 = qml.Hadamard.decomposition([1])
    assert op1 == op2

Co-authored-by: Maria Schuld <mariaschuld@gmail.com>
@mariaschuld mariaschuld self-requested a review November 15, 2021 14:45
Copy link
Contributor

@mariaschuld mariaschuld left a comment

Choose a reason for hiding this comment

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

Approval for if CI is happy now :)

@glassnotes glassnotes merged commit 927c4c6 into master Nov 15, 2021
@glassnotes glassnotes deleted the sc-10943-make-decomposition-a-method-of-the-main-operator branch November 15, 2021 14:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

3 participants