# Copyright 2021-2024 Cambridge Quantum Computing Ltd.
#
# Licensed under the Apache License, Version 2.0 (the 'License');
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an 'AS IS' BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
# implied. See the License for the specific language governing
# permissions and limitations under the License.
"""
PennyLane interface
===================
Lambeq's interface with Pennylane circuits. This work is
based on DisCoPy (https://discopy.org/) which is released under the
BSD 3-Clause 'New' or 'Revised' License.
Notes
-----
If `probabilities` is set to False, the output states of the PennyLane
circuit will be exactly equivalent to those of the lambeq circuit
(for the same parameters).
If `probabilities` is set to True, the output states of the PennyLane
circuit will be the probabilities of the output states, equivalent
to appending :class:`lambeq.backend.quantum.Measure` to all the
open wires in the lambeq circuit.
Once a :class:`PennyLaneCircuit` has been constructed, it
can be evaluated with :func:`.eval()`. If the circuit contains only
concrete parameters (i.e. no symbolic parameters), no arguments
should be passed to `eval()`. If the circuit contains symbolic
parameters, a list of the symbolic parameters and a list of their
associated weights should be passed to `eval()` as `symbols=` and
`weights=`.
"""
from __future__ import annotations
from itertools import product
import sys
from typing import List, Set, Tuple, TYPE_CHECKING, Union
import pennylane as qml
import torch
from typing_extensions import Never
from lambeq.backend.quantum import (Gate, Measure,
readoff_circuital,
to_circuital)
from lambeq.backend.symbol import lambdify, Symbol
if TYPE_CHECKING:
from lambeq.backend.quantum import Diagram
OP_MAP = {
'H': qml.Hadamard,
'X': qml.PauliX,
'Y': qml.PauliY,
'Z': qml.PauliZ,
'S': qml.S,
'Sdg': lambda wires: qml.S(wires=wires).inv(),
'T': qml.T,
'Tdg': lambda wires: qml.T(wires=wires).inv(),
'Rx': qml.RX,
'Ry': qml.RY,
'Rz': qml.RZ,
'CX': qml.CNOT,
'CY': qml.CY,
'CZ': qml.CZ,
'CRx': qml.CRX,
'CRy': qml.CRY,
'CRz': qml.CRZ,
'CU1': lambda a, wires: qml.ctrl(qml.U1(a,
wires=wires[1]),
control=wires[0]),
'SWAP': qml.SWAP,
'noop': qml.Identity,
}
[docs]
def to_pennylane(diagram: Diagram,
probabilities=False,
backend_config=None,
diff_method='best') -> PennyLaneCircuit:
"""
Return a PennyLaneCircuit equivalent to the input lambeq
circuit. `probabilities` determines whether the PennyLaneCircuit
returns states (as in lambeq), or probabilities (to be more
compatible with automatic differentiation in PennyLane).
Parameters
----------
diagram : :class:`lambeq.backend.quantum.Diagram`
The lambeq circuit to convert to PennyLane.
probabilities : bool, default: False
Determines whether the PennyLane
circuit outputs states or un-normalized probabilities.
Probabilities can be used with more PennyLane backpropagation
methods.
backend_config : dict, default: None
A dictionary of PennyLane backend configration options,
including the provider (e.g. IBM or Honeywell), the device,
the number of shots, etc. See the `PennyLane plugin
documentation <https://pennylane.ai/plugins/>`_
for more details.
diff_method : str, default: "best"
The differentiation method to use to obtain gradients for the
PennyLane circuit. Some gradient methods are only compatible
with simulated circuits. See the `PennyLane documentation
<https://docs.pennylane.ai/en/stable/introduction/interfaces.html>`_
for more details.
Returns
-------
:class:`PennyLaneCircuit`
The PennyLane circuit equivalent to the input lambeq circuit.
"""
if any(isinstance(box, Measure) for box in diagram.boxes):
raise ValueError('Only pure circuits, or circuits with discards'
' are currently supported.')
if diagram.is_mixed and diagram.cod:
# Some qubits discarded, some left open
print('Warning: Circuit includes both discards and open codomain'
' wires. All open wires will be discarded during conversion',
file=sys.stderr)
is_mixed = diagram.is_mixed
if not diagram.is_circuital:
diagram = to_circuital(diagram)
circuit_info = readoff_circuital(diagram)
scalar = 1.0
for gate in circuit_info.gates:
if gate.gtype == 'Scalar' and gate.phase is not None:
scalar *= gate.phase
circuit_info.gates.remove(gate)
ex_ops = extract_ops_from_circuital(circuit_info.gates)
op_list, params_list, symbols_set, wires_list = ex_ops
# Get post selection bits
post_selection = circuit_info.postmap
return PennyLaneCircuit(op_list,
list(symbols_set),
params_list,
wires_list,
probabilities,
post_selection,
is_mixed,
scalar,
circuit_info.total_qubits,
backend_config,
diff_method)
STATE_BACKENDS = ['default.qubit', 'lightning.qubit', 'qiskit.aer']
STATE_DEVICES = ['aer_simulator_statevector', 'statevector_simulator']
[docs]
class PennyLaneCircuit:
"""Implement a pennylane circuit with post-selection."""
[docs]
def __init__(self, ops, symbols, params, wires, probabilities,
post_selection, mixed, scale, n_qubits, backend_config,
diff_method):
self._ops = ops
self._symbols = symbols
self._params = params
self._wires = wires
self._probabilities = probabilities
self._post_selection = post_selection
self._mixed = mixed
self._scale = scale
self._n_qubits = n_qubits
self._backend_config = backend_config
self.diff_method = diff_method
self._contains_symbols = self.contains_symbols()
if self._contains_symbols:
self._concrete_params = None
else:
self._concrete_params = params
self.initialise_device_and_circuit()
self._valid_states = self.get_valid_states()
[docs]
def get_device(self, backend_config):
"""
Return a PennyLane device with the specified backend
configuration.
"""
if backend_config is None:
backend = 'default.qubit'
backend_config = {}
else:
backend = backend_config.pop('backend')
if backend == 'honeywell.hqs':
try:
backend_config['machine'] = backend_config.pop('device')
except KeyError:
raise ValueError('When using the honeywell.hqs provider, '
'a device must be specified.')
elif 'device' in backend_config:
backend_config['backend'] = backend_config.pop('device')
if not self._probabilities:
if backend not in STATE_BACKENDS:
raise ValueError(f'The {backend} backend is not '
'compatible with state outputs.')
elif ('backend' in backend_config
and backend_config['backend'] not in STATE_DEVICES):
raise ValueError(f'The {backend_config["backend"]} '
'device is not compatible with state '
'outputs.')
return qml.device(backend, wires=self._n_qubits, **backend_config)
[docs]
def initialise_device_and_circuit(self):
"""
Initialise the PennyLane device and circuit when instantiating the
PennyLaneCirucit, or loading from disk.
"""
self._device = self.get_device(None if self._backend_config is None
else {**self._backend_config})
self._circuit = self.make_circuit()
[docs]
def contains_symbols(self):
"""
Determine if the circuit parameters are
concrete or contain SymPy symbols.
Returns
-------
bool
Whether the circuit parameters contain SymPy symbols.
"""
return any(isinstance(expr, Symbol) for expr_list in
self._params for expr in expr_list)
[docs]
def initialise_concrete_params(self, symbol_weight_map):
"""
Given concrete values for each of the SymPy symbols, substitute
the symbols for the values to obtain concrete parameters, via
the `param_substitution` method.
"""
if self._contains_symbols:
weights = [symbol_weight_map[symbol] for symbol in self._symbols]
self._concrete_params = self.param_substitution(weights)
[docs]
def draw(self):
"""
Print a string representation of the circuit
similar to `qml.draw`, but including post-selection.
Parameters
----------
symbols : list of :class:`lambeq.Symbol`, default: None
The symbols from the original lambeq circuit.
weights : list of :class:`torch.FloatTensor`, default: None
The weights to substitute for the symbols.
"""
if self._concrete_params is None:
raise ValueError('Cannot draw circuit with symbolic parameters. '
'Initialise concrete parameters first.')
wires = (qml.draw(self._circuit)
(self._concrete_params).split('\n'))
for k, v in self._post_selection.items():
wires[k] = wires[k].split('┤')[0] + '┤' + str(v) + '>'
print('\n'.join(wires))
[docs]
def get_valid_states(self):
"""
Determine which of the output states of the circuit are
compatible with the post-selections.
Returns
-------
list of int
The indices of the circuit output that are
compatible with the post-selections.
"""
keep_indices = []
fixed = ['0' if self._post_selection.get(i, 0) == 0 else
'1' for i in range(self._n_qubits)]
open_wires = set(range(self._n_qubits)) - self._post_selection.keys()
permutations = [''.join(s) for s in product('01',
repeat=len(open_wires))]
for perm in permutations:
new = fixed.copy()
for i, open in enumerate(open_wires):
new[open] = perm[i]
keep_indices.append(int(''.join(new), 2))
return keep_indices
[docs]
def make_circuit(self):
"""
Construct the :class:`qml.Qnode`, a circuit that can be used with
autograd to construct hybrid models.
Returns
-------
:class:`qml.Qnode`
A Pennylane circuit without post-selection.
"""
@qml.qnode(self._device, interface='torch',
diff_method=self.diff_method)
def circuit(circ_params):
for op, params, wires in zip(self._ops, circ_params, self._wires):
op(*[2 * torch.pi * p for p in params], wires=wires)
if self._mixed:
return qml.density_matrix(self._post_selection.keys())
if self._probabilities:
return qml.probs(wires=range(self._n_qubits))
else:
return qml.state()
return circuit
[docs]
def post_selected_circuit(self, params):
"""
Run the circuit with the given parameters and return
the post-selected output.
Parameters
----------
params : :class:`torch.FloatTensor`
The concrete parameters for the gates in the circuit.
Returns
-------
:class:`torch.Tensor`
The post-selected output of the circuit.
"""
states = self._circuit(params)
if self._mixed:
# Select the all-zeros subsystem
return states[0][0]
open_wires = self._n_qubits - len(self._post_selection)
post_selected_states = states[self._valid_states]
post_selected_states *= (self._scale ** 2 if self._probabilities
else self._scale)
if post_selected_states.shape[0] == 1:
return post_selected_states
else:
return torch.reshape(post_selected_states, (2,) * open_wires)
[docs]
def param_substitution(self, weights):
"""
Substitute symbolic parameters (`lambeq.Symbol`s) with floats.
Parameters
----------
weights : list of :class:`torch.FloatTensor`
The weights to substitute for the symbols.
Returns
-------
:class:`torch.FloatTensor`
The concrete (non-symbolic) parameters for the
circuit.
"""
concrete_params = []
for expr_list in self._params:
concrete_list = []
for expr in expr_list:
if isinstance(expr, Symbol):
f_expr = lambdify(self._symbols, expr)
expr = f_expr(*weights)
concrete_list.append(expr)
concrete_params.append(concrete_list)
return concrete_params
[docs]
def eval(self):
"""
Evaluate the circuit. The symbols should be those
from the original lambeq diagram, which will be substituted
for the concrete parameters in weights.
Parameters
----------
symbols : list of :class:`lambeq.Symbol`, default: None
The symbols from the original lambeq circuit.
weights : list of :class:`torch.FloatTensor`, default: None
The weights to substitute for the symbols.
Returns
-------
:class:`torch.Tensor`
The post-selected output of the circuit.
"""
if self._concrete_params is None:
raise ValueError('Initialise concrete parameters first.')
return self.post_selected_circuit(self._concrete_params)