Files
final-qibotn/src/qibotn/backends/qmatchatea.py

318 lines
12 KiB
Python

"""Implementation of Quantum Matcha Tea backend."""
import re
from dataclasses import dataclass
import numpy as np
import qiskit
import qmatchatea
import qtealeaves
from qibo.backends import NumpyBackend
from qibo.config import raise_error
from qibotn.backends.abstract import QibotnBackend
from qibotn.result import TensorNetworkResult
@dataclass
class QMatchaTeaBackend(QibotnBackend, NumpyBackend):
def __init__(self):
super().__init__()
self.name = "qibotn"
self.platform = "qmatchatea"
# Set default configurations
self.configure_tn_simulation()
self._setup_backend_specifics()
def configure_tn_simulation(
self,
ansatz: str = "MPS",
convergence_params=None,
):
"""Configure TN simulation given Quantum Matcha Tea interface.
Args:
ansatz (str): tensor network ansatz. It can be tree tensor network "TTN"
or Matrix Product States "MPS" (default).
convergence_params (qmatchatea.utils.QCConvergenceParameters):
convergence parameters class adapted to the quantum computing
execution. See https://baltig.infn.it/quantum_matcha_tea/py_api_quantum_matcha_tea/-/blob/master/qmatchatea/utils/utils.py?ref_type=heads#L540
for more instructions. If not passed, the default values proposed
by Quantum Matcha Tea's authors are set.
"""
# Set configurations or defaults
self.convergence_params = (
convergence_params or qmatchatea.QCConvergenceParameters()
)
self.ansatz = ansatz
def _setup_backend_specifics(self):
"""Configure qmatchatea QCBackend object."""
qmatchatea_device = (
"cpu" if "CPU" in self.device else "gpu" if "GPU" in self.device else None
)
qmatchatea_precision = (
"C"
if self.precision == "single"
else "Z" if self.precision == "double" else "A"
)
# TODO: once MPI is available for Python, integrate it here
self.qmatchatea_backend = qmatchatea.QCBackend(
backend="PY", # The only alternative is Fortran, but we use Python here
precision=qmatchatea_precision,
device=qmatchatea_device,
ansatz=self.ansatz,
)
def execute_circuit(
self,
circuit,
initial_state=None,
nshots=None,
prob_type=None,
return_array=False,
**prob_kwargs,
):
"""Execute a Qibo quantum circuit using tensor network simulation.
This method returns a ``TensorNetworkResult`` object, which provides:
- Reconstruction of the system state (if the system size is < 20).
- Frequencies (if the number of shots is specified).
- Probabilities computed using various methods.
The following probability computation methods are available, as implemented
in Quantum Matcha Tea:
- **"E" (Even):** Probabilities are computed by evenly descending the probability tree,
pruning branches (states) with probabilities below a threshold.
- **"G" (Greedy):** Probabilities are computed by following the most probable states
in descending order until reaching a given coverage (sum of probabilities).
- **"U" (Unbiased):** An optimal probability measure that is unbiased and designed
for best performance. See https://arxiv.org/abs/2401.10330 for details.
Args:
circuit: A Qibo circuit to execute.
initial_state: The initial state of the system (default is the vacuum state
for tensor network simulations).
nshots: The number of shots for shot-noise simulation (optional).
prob_type: The probability computation method. Must be one of:
- "E" (Even)
- "G" (Greedy)
- "U" (Unbiased) [default].
prob_kwargs: Additional parameters required for probability computation:
- For "U", requires ``num_samples``.
- For "E" and "G", requires ``prob_threshold``.
Returns:
TensorNetworkResult: An object with methods to reconstruct the state,
compute probabilities, and generate frequencies.
"""
# TODO: verify if the QCIO mechanism of matcha is supported by Fortran only
# as written in the docstrings or by Python too (see ``io_info`` argument of
# ``qmatchatea.interface.run_simulation`` function)
if initial_state is not None:
raise_error(
NotImplementedError,
f"Backend {self.name}-{self.platform} currently does not support initial state.",
)
if prob_type == None:
prob_type = "U"
prob_kwargs = {"num_samples": 500}
# To be sure the setup is correct and no modifications have been done
self._setup_qmatchatea_backend()
# TODO: check
circuit = self._qibocirc_to_qiskitcirc(circuit)
run_qk_params = qmatchatea.preprocessing.qk_transpilation_params(False)
# Initialize the TNObservable object
observables = qtealeaves.observables.TNObservables()
# Shots
if nshots is not None:
observables += qtealeaves.observables.TNObsProjective(num_shots=nshots)
# Probabilities
observables += qtealeaves.observables.TNObsProbabilities(
prob_type=prob_type,
**prob_kwargs,
)
# State
observables += qtealeaves.observables.TNState2File(name="temp", formatting="U")
results = qmatchatea.run_simulation(
circ=circuit,
convergence_parameters=self.convergence_params,
transpilation_parameters=run_qk_params,
backend=self.qmatchatea_backend,
observables=observables,
)
if circuit.num_qubits < 20 and return_array:
statevector = results.statevector
else:
statevector = None
return TensorNetworkResult(
nqubits=circuit.num_qubits,
backend=self,
measures=results.measures,
measured_probabilities=results.measure_probabilities,
prob_type=prob_type,
statevector=statevector,
)
def expectation(self, circuit, observable):
"""Compute the expectation value of a Qibo-friendly ``observable`` on
the Tensor Network constructed from a Qibo ``circuit``.
This method takes a Qibo-style symbolic Hamiltonian (e.g., `X(0)*Z(1) + 2.0*Y(2)*Z(0)`)
as the observable, converts it into a Quantum Matcha Tea (qmatchatea) observable
(using `TNObsTensorProduct` and `TNObsWeightedSum`), and computes its expectation
value using the provided circuit.
Args:
circuit: A Qibo quantum circuit object on which the expectation value
is computed. The circuit should be compatible with the qmatchatea
Tensor Network backend.
observable: The observable whose expectation value we want to compute.
This must be provided in the symbolic Hamiltonian form supported by Qibo
(e.g., `X(0)*Y(1)` or `Z(0)*Z(1) + 1.5*Y(2)`).
Returns:
qmatchatea.SimulationResult [TEMPORARY]
"""
# From Qibo to Qiskit
circuit = self._qibocirc_to_qiskitcirc(circuit)
run_qk_params = qmatchatea.preprocessing.qk_transpilation_params(False)
operators = qmatchatea.QCOperators()
observables = qtealeaves.observables.TNObservables()
# Add custom observable
observables += self._qiboobs_to_qmatchaobs(hamiltonian_form=observable)
results = qmatchatea.run_simulation(
circ=circuit,
convergence_parameters=self.convergence_params,
transpilation_parameters=run_qk_params,
backend=self.qmatchatea_backend,
observables=observables,
operators=operators,
)
return np.real(results.observables["custom_hamiltonian"])
def _qibocirc_to_qiskitcirc(self, qibo_circuit) -> qiskit.QuantumCircuit:
"""Convert a Qibo Circuit into a Qiskit Circuit."""
# Convert the circuit to QASM 2.0 to qiskit
qasm_circuit = qibo_circuit.to_qasm()
qiskit_circuit = qiskit.QuantumCircuit.from_qasm_str(qasm_circuit)
# Transpile the circuit to adapt it to the linear structure of the MPS,
# with the constraint of having only the gates basis_gates
qiskit_circuit = qmatchatea.preprocessing.preprocess(
qiskit_circuit,
qk_params=qmatchatea.preprocessing.qk_transpilation_params(),
)
return qiskit_circuit
def _qiboobs_to_qmatchaobs(
self, hamiltonian_form, observable_name="custom_hamiltonian"
):
"""Convert a Qibo-style symbolic expression (e.g. '2.0*Y2*Z0 + Z0*Z2')
into a qmatchatea ``TNObsWeightedSum`` observable.
The parsing logic here assumes:
- Each term may have an optional leading coefficient (defaults to 1.0).
- Each operator is a single-letter from [XYZI] plus a qubit index (e.g., 'X2' means X on qubit 2).
- Terms are separated by '+' (and optionally '-') signs. If negative, we parse it as a negative coefficient.
Args:
hamiltonian_form: e.g. 'Y2*Z0 + 2.5*Z0*Z2'
observable_name (str): A name for the resulting ``TNObsWeightedSum``.
Returns:
TNObsWeightedSum: An observable suitable for qmatchatea.
"""
hamiltonian_form = str(hamiltonian_form)
# Collect all the simple terms in the string and preserve the sign
# whenever a coefficient is negative
hamiltonian_form = hamiltonian_form.replace("-", "+-")
raw_terms = [t.strip() for t in hamiltonian_form.split("+") if t.strip()]
coeff_list = []
# Regex for leading coefficient: e.g. "2.5*" or "-0.3*"
# group(1) will capture the numeric part, group(0) includes the sign if present
leading_coeff_pattern = re.compile(r"^([+-]?\d+(\.\d+)?)\*")
for i, hamiltonian_term in enumerate(raw_terms):
# Set default coefficient to 1.0
coeff = 1.0
# Look for a leading numeric coefficient
match = leading_coeff_pattern.search(hamiltonian_term)
if match:
# Parse that coefficient
coeff = float(match.group(1))
# Remove that portion from the term string so only operators remain
hamiltonian_term = leading_coeff_pattern.sub(
"", hamiltonian_term, count=1
)
# Now isolate the single terms in the product (if there are more than 1)
operators_qubits = hamiltonian_term.split("*")
# Prepare lists for qmatchatea
operator_names, acting_on_qubits = [], []
# Each sub-term is e.g. "Y2", so operator = "Y", qubit = 2
# We assume the operator is the single letter, the rest is the qubit index
for operator in operators_qubits:
operator = operator.strip()
# Use a regex to split the operator and the qubit index
match = re.match(r"([^\d]+)(\d+)", operator)
if match:
operator_name = match.group(
1
) # All characters before the number (e.g., 'XYZ')
qubit_index = int(match.group(2)) # The number part (e.g., 2)
operator_names.append(operator_name)
acting_on_qubits.append([qubit_index])
# Build collection of tensor product operators (tpo)
if i == 0:
tpo = qtealeaves.observables.TNObsTensorProduct(
name=f"{hamiltonian_term}",
operators=operator_names,
sites=acting_on_qubits,
)
else:
tpo += qtealeaves.observables.TNObsTensorProduct(
name=f"{hamiltonian_term}",
operators=operator_names,
sites=acting_on_qubits,
)
# And also keep track of coefficients
coeff_list.append(coeff)
# Combine everything into a WeightedSum
obs_sum = qtealeaves.observables.TNObsWeightedSum(
name=observable_name, tp_operators=tpo, coeffs=coeff_list, use_itpo=False
)
return obs_sum