Appending ops to a circuit doesn't work as expected

I am trying to implement a global folding from scratch as a learning project.

1. Prepare a Bell State

from typing import List
import pennylane as qml

noise_strength = 0.05
dev_noise_free = qml.device("default.mixed", wires=2)
dev = qml.transforms.insert(
    dev_noise_free,
    qml.DepolarizingChannel,
    noise_strength
)
def circuit():
    """
    A circuit preparing a Bell state
    """
    qml.Hadamard(wires=0)
    qml.CNOT(wires=[0, 1])
    return qml.expval(qml.PauliZ(0) @ qml.PauliZ(1))

2. Implement the unitary fold

def unitary_fold(circuit, scale_factor: int):
    # original ops
    circuit()   # somehow I have to run in so that `.tape` works
    # take all the operations of a circuit
    original_ops = circuit.tape.operations
    ops = circuit.tape.copy(copy_operations=True).operations
    n, s = divmod(scale_factor - 1, 2)    

    # For the 1st part  (U^H U)**n
    for i in range(n):
        for op in original_ops[::-1]:            
            ops.append(qml.adjoint(op))
        for op in original_ops:            
            ops.append(op)
    
    # For the 2nd part (L_d^H .. L_s^H) (L_s .. L_d)
    if s > 0:
        last_layers = original_ops[-s:]
        for op in last_layers[::-1]:
            ops.append(qml.adjoint(op))
        for i in last_layers:
            ops.append(op)

    # Return list of op to create the circuit
    return ops, circuit.tape.measurements

3. Test run
The conundrum is in this session

@qml.qnode(dev)
def circuit_from_ops(operations: List, measurements: List):
    for op in operations[:]:     ## This line is important
        qml.apply(op)
        print(op)
    return qml.apply(measurements[0])

device_circuit = qml.QNode(circuit, dev)
ops, measurements = unitary_fold(device_circuit, 5)
print(qml.draw(circuit_from_ops)(ops, measurements))

It would give me

0: ──H─╭●─╭●───H†─╭●───H†──H─╭●── β•­<Z@Z>
1: ────╰X─╰X†─────╰X†────────╰X── β•°<Z@Z>

In the 3rd line above, if I change operations[:] to operations[:6], then I have

0: ──H─╭●─╭●───H†──H─╭●── β•­<Z@Z>
1: ────╰X─╰X†────────╰X── β•°<Z@Z>

Which is correct
But if I change operations[:] to operations[:7], then I have

0: ──H─╭●─╭●───H†──H─╭●─── β•­<Z@Z>
1: ────╰X─╰X†────────╰X†── β•°<Z@Z>

Note the last CNOT was replaced by its adjoint, instead of adding a new adjoint CNOT like below

0: ──H─╭●─╭●───H†──H─╭●─╭●─── β•­<Z@Z>
1: ────╰X─╰X†────────╰X─╰X†── β•°<Z@Z>

I don’t understand what I am doint wrong here?

qml.about

Name: PennyLane
Version: 0.35.1
Summary: PennyLane is a cross-platform Python library for quantum computing, quantum machine learning, and quantum chemistry. Train a quantum computer the same way as a neural network.
Home-page: GitHub - PennyLaneAI/pennylane: PennyLane is a cross-platform Python library for differentiable programming of quantum computers. Train a quantum computer the same way as a neural network.
Author:
Author-email:
License: Apache License 2.0
Location: c:\code\zne\venv\lib\site-packages
Requires: appdirs, autograd, autoray, cachetools, networkx, numpy, pennylane-lightning, requests, rustworkx, scipy, semantic-version, toml, typing-extensions
Required-by: PennyLane-qiskit, PennyLane_Lightning

Platform info: Windows-10-10.0.19045-SP0
Python version: 3.10.9
Numpy version: 1.26.4
Scipy version: 1.11.4
Installed devices:

  • default.clifford (PennyLane-0.35.1)
  • default.gaussian (PennyLane-0.35.1)
  • default.mixed (PennyLane-0.35.1)
  • default.qubit (PennyLane-0.35.1)
  • default.qubit.autograd (PennyLane-0.35.1)
  • default.qubit.jax (PennyLane-0.35.1)
  • default.qubit.legacy (PennyLane-0.35.1)
  • default.qubit.tf (PennyLane-0.35.1)
  • default.qubit.torch (PennyLane-0.35.1)
  • default.qutrit (PennyLane-0.35.1)
  • null.qubit (PennyLane-0.35.1)
  • lightning.qubit (PennyLane_Lightning-0.35.1)
  • qiskit.aer (PennyLane-qiskit-0.35.1)
  • qiskit.basicaer (PennyLane-qiskit-0.35.1)
  • qiskit.ibmq (PennyLane-qiskit-0.35.1)
  • qiskit.ibmq.circuit_runner (PennyLane-qiskit-0.35.1)
  • qiskit.ibmq.sampler (PennyLane-qiskit-0.35.1)
  • qiskit.remote (PennyLane-qiskit-0.35.1)

Hey @mchau, welcome to the forum! :rocket:

I appreciate your detailed post! I’m able to replicate what you’re seeing, and I have to admit I’m stumped :thinking:. If I isolate out your unitary_fold function, everything behaves nicely:

import pennylane as qml

dev = qml.device("default.mixed", wires=2)

ops = [qml.RX(0.1, wires=[0]), qml.adjoint(qml.RX(0.1, wires=[0])), qml.RX(0.1, wires=[0]), qml.adjoint(qml.RX(0.1, wires=[0]))]

@qml.qnode(dev)
def circuit_from_ops(operations, n):
    #ops = operations[:6]
    print(operations[:int(n)])
    for op in operations[:int(n)]:     ## This line is important
        qml.apply(op)
        #print(op)
    return qml.state()

for n in [3, 4]:
    circuit_from_ops(ops, n)
    print(circuit_from_ops.tape.operations)
    print()
[RX(0.1, wires=[0]), Adjoint(RX(0.1, wires=[0])), RX(0.1, wires=[0])]
[RX(0.1, wires=[0]), Adjoint(RX(0.1, wires=[0])), RX(0.1, wires=[0])]

[RX(0.1, wires=[0]), Adjoint(RX(0.1, wires=[0])), RX(0.1, wires=[0]), Adjoint(RX(0.1, wires=[0]))]
[RX(0.1, wires=[0]), Adjoint(RX(0.1, wires=[0])), RX(0.1, wires=[0]), Adjoint(RX(0.1, wires=[0]))]

But when I use your unitary_fold function to obtain the operators, I get the behaviour you see:

dev = qml.device("default.mixed", wires=2) # this can be default.qubit, too, doesn't matter.

def circuit():
    qml.RX(0.1, wires=[0])
    return qml.expval(qml.PauliZ(0))

def unitary_fold(circuit, scale_factor: int):
    # original ops
    circuit()   # somehow I have to run in so that `.tape` works
    # take all the operations of a circuit
    original_ops = circuit.tape.operations
    ops = circuit.tape.copy(copy_operations=True).operations
    n, s = divmod(scale_factor - 1, 2)    

    # For the 1st part  (U^H U)**n
    for i in range(n):
        for op in original_ops[::-1]:            
            ops.append(qml.adjoint(op))
        for op in original_ops:            
            ops.append(op)
    
    # For the 2nd part (L_d^H .. L_s^H) (L_s .. L_d)
    if s > 0:
        last_layers = original_ops[-s:]
        for op in last_layers[::-1]:
            ops.append(qml.adjoint(op))
        for i in last_layers:
            ops.append(op)

    # Return list of op to create the circuit
    return ops, circuit.tape.measurements
@qml.qnode(dev)
def circuit_from_ops(operations, n):
    #ops = operations[:6]
    print(operations[:int(n)])
    for op in operations[:int(n)]:     ## This line is important
        qml.apply(op)
        #print(op)
    return qml.state()

device_circuit = qml.QNode(circuit, dev)
ops, measurements = unitary_fold(device_circuit, 5)

for n in [3, 4]:
    circuit_from_ops(ops, n)
    print(circuit_from_ops.tape.operations)
    print()
[RX(0.1, wires=[0]), Adjoint(RX(0.1, wires=[0])), RX(0.1, wires=[0])]
[RX(0.1, wires=[0]), Adjoint(RX(0.1, wires=[0])), RX(0.1, wires=[0])]

[RX(0.1, wires=[0]), Adjoint(RX(0.1, wires=[0])), RX(0.1, wires=[0]), Adjoint(RX(0.1, wires=[0]))]
[RX(0.1, wires=[0]), Adjoint(RX(0.1, wires=[0])), Adjoint(RX(0.1, wires=[0]))]

I’m confused :exploding_head:. I’ll get back to you!

1 Like

Here’s a more minimal example of the problem you are seeing:

op = qml.X(0)
ops = [op, qml.adjoint(op)]

@qml.qnode(qml.device('default.qubit'))
def circuit(i):
    for obj in ops[:i]:
        qml.apply(obj)
    return qml.expval(qml.Z(0))

print(qml.draw(circuit)(1))
print(qml.draw(circuit)(2))
0: ──X──  <Z>
0: ──X†──  <Z>

Basically, when you are queuing an adjoint operator, pennylane always removes the original target operation. This is so that someone can do qml.adjoint(qml.X(0)) and not end up with both X(0) and Adjoint(X(0)) in the circuit.

One potential solution would be to add copies.

        for op in original_ops[::-1]:            
            ops.append(qml.adjoint(copy.copy(op)))

I might also recommend you looking into writing you own custom transform (See qml.transforms β€” PennyLane 0.35.1 documentation ).

Transforms bypass queuing, so you can just ignore this whole problem:

def null_processsing(results):
    return results[0]

@qml.transform
def unitary_fold(tape, scale_factor: int):
    ops = tape.operations
    n, s = divmod(scale_factor - 1, 2)    

    # For the 1st part  (U^H U)**n
    for i in range(n):
        for op in original_ops[::-1]:            
            ops.append(qml.adjoint(op))
        for op in original_ops:            
            ops.append(op)
    
    # For the 2nd part (L_d^H .. L_s^H) (L_s .. L_d)
    if s > 0:
        last_layers = original_ops[-s:]
        for op in last_layers[::-1]:
            ops.append(qml.adjoint(op))
        for i in last_layers:
            ops.append(op)

    new_tape = qml.tape.QuantumScript(ops, tape.measurements, shots=tape.shots)
    return (new_tape,), null_processing

folded_circuit = unitary_fold(circuit, 5)
2 Likes

Thank you both. I see that in the unitary fold they use QuantumScript and QuantumTape, but I tried to make it a circuit because I cannot find a way to draw these objects. draw() is also the reason I learn about this interesting behavior

from you.
Is there a way to either convert a QuantumScript / QuantumTape to a circuit to draw, or draw them directly?

Of course you already told me this

I just spent a lot of time to draw a Tape / Script so I am still curious. circuit seems to be very user friendly, but there must be good reasons why Tape / Script exist

You can do qml.drawer.tape_text(tape) to draw a tape.

A QuantumScript (tape without unnecessary baggage) is our internal way of representing a circuit. As you’ve seen, it’s much more efficient and robust to errors by directly working with an iterable of operators, instead of dealing with all the queuing black magic and overheads.

1 Like