PennyLaneAI / pennylane

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.
https://pennylane.ai
Apache License 2.0
2.34k stars 599 forks source link

[BUG] Mid-circuit measurements cannot be added to tapes in transforms #6011

Closed glassnotes closed 3 months ago

glassnotes commented 3 months ago

Expected behavior

When a mid-circuit measurement is applied in a transform, the measurement is correctly queued to the tape.

Actual behavior

An error is thrown, due to measurement processes not having the same properties as regular operations.

Additional information

This is both a bug report and a feature request. There are specific workflows where this, as well as the ability to sample and return the values of mid-circuit measurements, would be really useful.

Source code

@qml.transform
def add_mcm(tape):
    new_operations = [] 
    measurements = []

    with qml.QueuingManager.stop_recording():
        for op in tape.operations:
            new_operations.append(op)

            # Applies a mid-circuit measurement and adds to tape operations
            mcm = qml.measure(wires=0, reset=True)
            new_operations.append(mcm)

    new_tape = type(tape)(new_operations, tape.measurements)

    def null_postprocessing(results,):
        return results[0]

    return ([new_tape], null_postprocessing)

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

@qml.dynamic_one_shot
@add_mcm
@qml.qnode(dev)
def apply_mcm():
    qml.Hadamard(wires=0)
    return qml.probs(wires=0)

Tracebacks

---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
Cell In[39], line 1
----> 1 apply_mcm()

File ~/Software/anaconda3/envs/qecc/lib/python3.12/site-packages/pennylane/workflow/qnode.py:1164, in QNode.__call__(self, *args, **kwargs)
   1162 if qml.capture.enabled():
   1163     return qml.capture.qnode_call(self, *args, **kwargs)
-> 1164 return self._impl_call(*args, **kwargs)

File ~/Software/anaconda3/envs/qecc/lib/python3.12/site-packages/pennylane/workflow/qnode.py:1150, in QNode._impl_call(self, *args, **kwargs)
   1147 self._update_gradient_fn(shots=override_shots, tape=self._tape)
   1149 try:
-> 1150     res = self._execution_component(args, kwargs, override_shots=override_shots)
   1151 finally:
   1152     if old_interface == "auto":

File ~/Software/anaconda3/envs/qecc/lib/python3.12/site-packages/pennylane/workflow/qnode.py:1103, in QNode._execution_component(self, args, kwargs, override_shots)
   1100 _prune_dynamic_transform(full_transform_program, inner_transform_program)
   1102 # pylint: disable=unexpected-keyword-arg
-> 1103 res = qml.execute(
   1104     (self._tape,),
   1105     device=self.device,
   1106     gradient_fn=self.gradient_fn,
   1107     interface=self.interface,
   1108     transform_program=full_transform_program,
   1109     inner_transform=inner_transform_program,
   1110     config=config,
   1111     gradient_kwargs=self.gradient_kwargs,
   1112     override_shots=override_shots,
   1113     **self.execute_kwargs,
   1114 )
   1115 res = res[0]
   1117 # convert result to the interface in case the qfunc has no parameters

File ~/Software/anaconda3/envs/qecc/lib/python3.12/site-packages/pennylane/workflow/execution.py:835, in execute(tapes, device, gradient_fn, interface, transform_program, inner_transform, config, grad_on_execution, gradient_kwargs, cache, cachesize, max_diff, override_shots, expand_fn, max_expansion, device_batch_transform, device_vjp, mcm_config)
    827 ml_boundary_execute = _get_ml_boundary_execute(
    828     interface,
    829     _grad_on_execution,
    830     config.use_device_jacobian_product,
    831     differentiable=max_diff > 1,
    832 )
    834 if interface in jpc_interfaces:
--> 835     results = ml_boundary_execute(tapes, execute_fn, jpc, device=device)
    836 else:
    837     results = ml_boundary_execute(
    838         tapes, device, execute_fn, gradient_fn, gradient_kwargs, _n=1, max_diff=max_diff
    839     )

File ~/Software/anaconda3/envs/qecc/lib/python3.12/site-packages/pennylane/workflow/interfaces/autograd.py:139, in autograd_execute(tapes, execute_fn, jpc, device)
    136     logger.debug("Entry with (tapes=%s, execute_fn=%s, jpc=%s)", tapes, execute_fn, jpc)
    137 for tape in tapes:
    138     # set the trainable parameters
--> 139     params = tape.get_parameters(trainable_only=False)
    140     tape.trainable_params = qml.math.get_trainable_indices(params)
    142 # pylint misidentifies autograd.builtins as a dict
    143 # pylint: disable=no-member

File ~/Software/anaconda3/envs/qecc/lib/python3.12/site-packages/pennylane/tape/qscript.py:662, in QuantumScript.get_parameters(self, trainable_only, operations_only, **kwargs)
    658     return params
    660 # If trainable_only=False, return all parameters
    661 # This is faster than the above and should be used when indexing `par_info` is not needed
--> 662 params = [d for op in self.operations for d in op.data]
    663 if operations_only:
    664     return params

AttributeError: 'MeasurementValue' object has no attribute 'data'

System information

Name: PennyLane
Version: 0.37.0
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: https://github.com/PennyLaneAI/pennylane
Author: 
Author-email: 
License: Apache License 2.0
Location: /home/olivia/Software/anaconda3/envs/qecc/lib/python3.12/site-packages
Requires: appdirs, autograd, autoray, cachetools, networkx, numpy, packaging, pennylane-lightning, requests, rustworkx, scipy, semantic-version, toml, typing-extensions
Required-by: PennyLane_Lightning

Platform info:           Linux-6.5.0-41-generic-x86_64-with-glibc2.35
Python version:          3.12.4
Numpy version:           1.26.4
Scipy version:           1.14.0
Installed devices:
- lightning.qubit (PennyLane_Lightning-0.37.0)
- default.clifford (PennyLane-0.37.0)
- default.gaussian (PennyLane-0.37.0)
- default.mixed (PennyLane-0.37.0)
- default.qubit (PennyLane-0.37.0)
- default.qubit.autograd (PennyLane-0.37.0)
- default.qubit.jax (PennyLane-0.37.0)
- default.qubit.legacy (PennyLane-0.37.0)
- default.qubit.tf (PennyLane-0.37.0)
- default.qubit.torch (PennyLane-0.37.0)
- default.qutrit (PennyLane-0.37.0)
- default.qutrit.mixed (PennyLane-0.37.0)
- default.tensor (PennyLane-0.37.0)
- null.qubit (PennyLane-0.37.0)

Existing GitHub issues

glassnotes commented 3 months ago

Side note: it is possible to apply MCMs within transforms, and perform operations controlled on their outputs. However the specific goal here is to be able to access and return samples of their outcomes.

For instance, achieving something like this:

@qml.dynamic_one_shot
@qml.qnode(dev)
def apply_mcm():
    qml.Hadamard(wires=0)
    mcm = qml.measure(wires=0, reset=True)
    return qml.sample(mcm)

with a transform structured like so:

@qml.transform
def add_mcm(tape):
    new_operations = [] 
    measurements = []

    with qml.QueuingManager.stop_recording():
        for op in tape.operations:
            new_operations.append(op)
            mcm = qml.measure(wires=0, reset=True)
            new_operations.append(mcm)

    new_tape = type(tape)(new_operations, [qml.sample(mcm)])

    def null_postprocessing(results,):
        return results[0]

    return ([new_tape], null_postprocessing)
mudit2812 commented 3 months ago

Hi @glassnotes , this is a consequence of the way we have defined qml.measure. It is a bit unintuitive in the sense that it doesn't actually return an operator, but rather an abstract representation of the measured value (a MeasurementValue object), and the mid-circuit measurement operator gets silently queued. MeasurementValue does hold a reference to the original mid-circuit measurement, so you can fix the transform this way:

@qml.transform
def add_mcm(tape):
    new_operations = [] 
    measurements = []

    with qml.QueuingManager.stop_recording():
        for op in tape.operations:
            new_operations.append(op)

            # Applies a mid-circuit measurement and adds to tape operations
            mcm = qml.measure(wires=0, reset=True)
            new_operations.append(mcm.measurements[0])

    new_tape = type(tape)(new_operations, tape.measurements)

    def null_postprocessing(results,):
        return results[0]

    return ([new_tape], null_postprocessing)

Feel free to reach out if this resolves/does not resolve the issue and I can investigate further 😄

mudit2812 commented 3 months ago

Want to quickly add a follow up. In the above example, you can use mcm as the conditional argument for qml.cond, or for collecting mid-circuit measurement statistics (qml.sample(mcm), etc.).

glassnotes commented 3 months ago

Thanks, @mudit2812 !

MeasurementValue does hold a reference to the original mid-circuit measurement, so you can fix the transform this way:

Yes, this fixes the output. The measurement doesn't show up in the list of operations in the tape (which would be helpful for debugging and determining if they are applied correctly).

Want to quickly add a follow up. In the above example, you can use mcm as the conditional argument for qml.cond, or for collecting mid-circuit measurement statistics (qml.sample(mcm), etc.).

I'm not sure I follow here. Even with the updated transform, using

new_tape = type(tape)(new_operations, [qml.sample(mcm.measurements)])

or even

    new_tape = type(tape)(new_operations, [qml.sample(mcm.measurements[0])])

does not work for actually returning the sampled values. Another thing I tried was to return the samples as an the output of the post-processing function (but this just returns the measurement object itself, and can't be executed).

mudit2812 commented 3 months ago

@glassnotes glad to hear that this is working.

The measurement doesn't show up in the list of operations in the tape (which would be helpful for debugging and determining if they are applied correctly).

This should not be the case. Working example at the bottom.

new_tape = type(tape)(new_operations, [qml.sample(mcm.measurements)])

Sorry if it wasn't clear, for collecting the mcm samples (or any other measurement), you should use qml.sample(mcm) rather than qml.sample(mcm.measurements) or qml.sample(mcm.measurements[0]). Working example at the bottom

@qml.transform
def add_mcm(tape):
    new_operations = []
    new_measurements = []

    with qml.QueuingManager.stop_recording():
        for op in tape.operations:
            new_operations.append(op)

            # Applies a mid-circuit measurement and adds to tape operations
            mcm = qml.measure(wires=0, reset=True)
            new_operations.append(mcm.measurements[0])
            # Collects samples on the mid-circuit measurement
            new_measurements.append(qml.sample(mcm))

    new_tape = type(tape)(new_operations, new_measurements)

    def null_postprocessing(
        results,
    ):
        return results[0]

    return ([new_tape], null_postprocessing)
>>> tape = qml.tape.QuantumScript([qml.RX(1.0, 0), qml.RX(2.0, 0), qml.RX(3.0, 0)], [qml.expval(qml.Z(0))])
>>> [new_tape], _ = add_mcm(tape)
>>> print(new_tape.operations)
[RX(1.0, wires=[0]), measure(wires=[0]), RX(2.0, wires=[0]), measure(wires=[0]), RX(3.0, wires=[0]), measure(wires=[0])]
>>> print(new_tape.measurements)
[sample(MeasurementValue(wires=[0])), sample(MeasurementValue(wires=[0])), sample(MeasurementValue(wires=[0]))]
glassnotes commented 3 months ago

@mudit2812 great - that solves it. But I did have to do something very specific. Your example with a tape gave me the idea. It seems that it needs to be applied as a quantum function transform rather than a QNode transform. So the following will work:

@qml.dynamic_one_shot
@qml.qnode(dev)
@add_mcm
def apply_mcm():
    qml.Hadamard(wires=0)
    return qml.probs(wires=0)
>>> apply_mcm()
tensor(1, requires_grad=True)
>>> qml.draw(apply_mcm)()
0: ──H──┤↗│  │0⟩─┤             
         ╚═══════╡  Sample[MCM]

But applying it as a QNode transform, i.e.,

@qml.dynamic_one_shot
@add_mcm
@qml.qnode(dev)
def apply_mcm():
    qml.Hadamard(wires=0)
    return qml.probs(wires=0)

throws a DeviceError:

---------------------------------------------------------------------------
DeviceError                               Traceback (most recent call last)
Cell In[13], line 1
----> 1 apply_mcm()

File [~/.conda/envs/qecc/lib/python3.12/site-packages/pennylane/workflow/qnode.py:1164](http://localhost:8888/home/olivia/.conda/envs/qecc/lib/python3.12/site-packages/pennylane/workflow/qnode.py#line=1163), in QNode.__call__(self, *args, **kwargs)
   1162 if qml.capture.enabled():
   1163     return qml.capture.qnode_call(self, *args, **kwargs)
-> 1164 return self._impl_call(*args, **kwargs)

File [~/.conda/envs/qecc/lib/python3.12/site-packages/pennylane/workflow/qnode.py:1150](http://localhost:8888/home/olivia/.conda/envs/qecc/lib/python3.12/site-packages/pennylane/workflow/qnode.py#line=1149), in QNode._impl_call(self, *args, **kwargs)
   1147 self._update_gradient_fn(shots=override_shots, tape=self._tape)
   1149 try:
-> 1150     res = self._execution_component(args, kwargs, override_shots=override_shots)
   1151 finally:
   1152     if old_interface == "auto":

File [~/.conda/envs/qecc/lib/python3.12/site-packages/pennylane/workflow/qnode.py:1103](http://localhost:8888/home/olivia/.conda/envs/qecc/lib/python3.12/site-packages/pennylane/workflow/qnode.py#line=1102), in QNode._execution_component(self, args, kwargs, override_shots)
   1100 _prune_dynamic_transform(full_transform_program, inner_transform_program)
   1102 # pylint: disable=unexpected-keyword-arg
-> 1103 res = qml.execute(
   1104     (self._tape,),
   1105     device=self.device,
   1106     gradient_fn=self.gradient_fn,
   1107     interface=self.interface,
   1108     transform_program=full_transform_program,
   1109     inner_transform=inner_transform_program,
   1110     config=config,
   1111     gradient_kwargs=self.gradient_kwargs,
   1112     override_shots=override_shots,
   1113     **self.execute_kwargs,
   1114 )
   1115 res = res[0]
   1117 # convert result to the interface in case the qfunc has no parameters

File [~/.conda/envs/qecc/lib/python3.12/site-packages/pennylane/workflow/execution.py:835](http://localhost:8888/home/olivia/.conda/envs/qecc/lib/python3.12/site-packages/pennylane/workflow/execution.py#line=834), in execute(tapes, device, gradient_fn, interface, transform_program, inner_transform, config, grad_on_execution, gradient_kwargs, cache, cachesize, max_diff, override_shots, expand_fn, max_expansion, device_batch_transform, device_vjp, mcm_config)
    827 ml_boundary_execute = _get_ml_boundary_execute(
    828     interface,
    829     _grad_on_execution,
    830     config.use_device_jacobian_product,
    831     differentiable=max_diff > 1,
    832 )
    834 if interface in jpc_interfaces:
--> 835     results = ml_boundary_execute(tapes, execute_fn, jpc, device=device)
    836 else:
    837     results = ml_boundary_execute(
    838         tapes, device, execute_fn, gradient_fn, gradient_kwargs, _n=1, max_diff=max_diff
    839     )

File [~/.conda/envs/qecc/lib/python3.12/site-packages/pennylane/workflow/interfaces/autograd.py:147](http://localhost:8888/home/olivia/.conda/envs/qecc/lib/python3.12/site-packages/pennylane/workflow/interfaces/autograd.py#line=146), in autograd_execute(tapes, execute_fn, jpc, device)
    142 # pylint misidentifies autograd.builtins as a dict
    143 # pylint: disable=no-member
    144 parameters = autograd.builtins.tuple(
    145     [autograd.builtins.list(t.get_parameters()) for t in tapes]
    146 )
--> 147 return _execute(parameters, tuple(tapes), execute_fn, jpc)

File [~/.conda/envs/qecc/lib/python3.12/site-packages/autograd/tracer.py:48](http://localhost:8888/home/olivia/.conda/envs/qecc/lib/python3.12/site-packages/autograd/tracer.py#line=47), in primitive.<locals>.f_wrapped(*args, **kwargs)
     46     return new_box(ans, trace, node)
     47 else:
---> 48     return f_raw(*args, **kwargs)

File [~/.conda/envs/qecc/lib/python3.12/site-packages/pennylane/workflow/interfaces/autograd.py:168](http://localhost:8888/home/olivia/.conda/envs/qecc/lib/python3.12/site-packages/pennylane/workflow/interfaces/autograd.py#line=167), in _execute(parameters, tapes, execute_fn, jpc)
    150 @autograd.extend.primitive
    151 def _execute(
    152     parameters,
   (...)
    155     jpc,
    156 ):  # pylint: disable=unused-argument
    157     """Autodifferentiable wrapper around a way of executing tapes.
    158 
    159     Args:
   (...)
    166 
    167     """
--> 168     return execute_fn(tapes)

File [~/.conda/envs/qecc/lib/python3.12/site-packages/pennylane/workflow/execution.py:309](http://localhost:8888/home/olivia/.conda/envs/qecc/lib/python3.12/site-packages/pennylane/workflow/execution.py#line=308), in _make_inner_execute.<locals>.inner_execute(tapes, **_)
    306 if cache is not None:
    307     transform_program.add_transform(_cache_transform, cache=cache)
--> 309 transformed_tapes, transform_post_processing = transform_program(tapes)
    311 # TODO: Apply expand_fn() as transform.
    312 if expand_fn:

File [~/.conda/envs/qecc/lib/python3.12/site-packages/pennylane/transforms/core/transform_program.py:515](http://localhost:8888/home/olivia/.conda/envs/qecc/lib/python3.12/site-packages/pennylane/transforms/core/transform_program.py#line=514), in TransformProgram.__call__(self, tapes)
    513 if self._argnums is not None and self._argnums[i] is not None:
    514     tape.trainable_params = self._argnums[i][j]
--> 515 new_tapes, fn = transform(tape, *targs, **tkwargs)
    516 execution_tapes.extend(new_tapes)
    518 fns.append(fn)

File [~/.conda/envs/qecc/lib/python3.12/site-packages/pennylane/devices/preprocess.py:490](http://localhost:8888/home/olivia/.conda/envs/qecc/lib/python3.12/site-packages/pennylane/devices/preprocess.py#line=489), in validate_measurements(tape, analytic_measurements, sample_measurements, name)
    488     for m in chain(snapshot_measurements, tape.measurements):
    489         if not analytic_measurements(m):
--> 490             raise DeviceError(
    491                 f"Measurement {m} not accepted for analytic simulation on {name}."
    492             )
    494 return (tape,), null_postprocessing

DeviceError: Measurement sample(MeasurementValue(wires=[1])) not accepted for analytic simulation on default.qubit.
mudit2812 commented 3 months ago

@glassnotes I think that is because you're executing a tape with qml.sample and the transformed tape does not copy shots from the original tape. The new tape should be created as new_tape = type(tape)(new_operations, new_measurements, trainable_params=tape.trainable_params, shots=tape.shots)

glassnotes commented 3 months ago

You're right! Thanks so much for your help. Feel free to close this issue (or at the very least, the bug tag can be removed). I am not sure how common a use-case this is, but could be something for the docs?

mudit2812 commented 3 months ago

@glassnotes great! Yes, we've discussed potential strategies to make this more intuitive. For now I'm closing this issue 😄