"""
Quantum Specific Metrics implementation for QWARD.
This module provides the QuantumSpecificMetrics class for extracting metrics
that quantify intrinsically quantum properties of a circuit within the QWARD
framework. These metrics capture phenomena that have no classical analogue and
directly reflect the quantum computational resources present in a circuit.
Quantum-specific metrics include measures of entanglement, coherence, sensitivity,
and non-Clifford “magic,” along with other indicators of quantum behavior that
define the circuit’s potential for quantum advantage and its complexity under
classical simulation. By focusing on fundamental quantum effects rather than
gate counts or structural arrangement, these metrics offer insight into the
circuit’s true quantum nature and resource requirements.
[1] K. Bu, R. J. Garcia, A. Jaffe, D. E. Koh y L. Li, “Complexity of quantum
circuits via sensitivity, magic, and coherence,” Communications in Mathematical
Physics, vol. 405, no. 7, 2024, doi:10.1007/s00220-024-05030-6.
[2] T. Tomesh, P. Gokhale, V. Omole, G. S. Ravi, K. N. Smith, J. Viszlai,
X.-C. Wu, N. Hardavellas, M. R. Martonosi y F. T. Chong, “SupermarQ: A scalable
quantum benchmark suite,” in Proc. 2022 IEEE International Symposium on
High-Performance Computer Architecture (HPCA), 2022, doi:
10.1109/HPCA53966.2022.00050.
[3] J. A. Cruz-Lemus, L. A. Marcelo, and M. Piattini, "Towards a set of metrics for
quantum circuits understandability," in *Quality of Information and Communications
Technology. QUATIC 2021 (Communications in Computer and Information Science, vol. 1439)
*, A. C. R. Paiva, A. R. Cavalli, P. Ventura Martins, and R. Pérez-Castillo, Eds. Cham:
Springer, 2021, pp. 238–253. doi: 10.1007/978-3-030-85347-1_18.
"""
import numpy as np
from typing import Dict, Any, List, Tuple, Optional
from qiskit import QuantumCircuit
from qiskit.quantum_info import Operator, partial_trace, DensityMatrix
from qiskit.converters import circuit_to_dag
from qward.metrics.base_metric import MetricCalculator
from qward.metrics.types import MetricsType, MetricsId
# Try to import torch, but handle gracefully if not available
try:
import torch
TORCH_AVAILABLE = True
except ImportError:
TORCH_AVAILABLE = False
torch = None
# Import schemas for structured data validation
try:
from qward.schemas.quantum_specific_metrics_schema import QuantumSpecificMetricsSchema
SCHEMAS_AVAILABLE = True
except ImportError:
SCHEMAS_AVAILABLE = False
# Gates that represent two-qubit interactions
TWO_QUBIT_GATES = {
"cx",
"cy",
"cz",
"swap",
"iswap",
"dcx",
"ecr",
"rxx",
"ryy",
"rzz",
"rzx",
"xx_minus_yy",
"xx_plus_yy",
"ccx",
"cswap",
"mcx",
"mcphase",
"mcu1",
"mcu2",
"mcu3",
"mcrx",
"mcry",
"mcrz",
"mcp",
"mcu",
"mcswap",
}
PAULI_SINGLE = {
"I": np.array([[1, 0], [0, 1]], dtype=complex),
"X": np.array([[0, 1], [1, 0]], dtype=complex),
"Y": np.array([[0, -1j], [1j, 0]], dtype=complex),
"Z": np.array([[1, 0], [0, -1]], dtype=complex),
}
[docs]
class QuantumSpecificMetrics(MetricCalculator):
"""
Extract intrinsically quantum metrics from QuantumCircuit objects.
Quantum-specific metrics provide insight into the true quantum behavior of
a circuit, its resource requirements, and its expected difficulty for
classical simulation. They complement structural and element-level metrics
by focusing exclusively on quantum effects arising from the circuit’s
transformations and state evolution.
Attributes:
circuit (QuantumCircuit):
The quantum circuit to analyze (inherited from MetricCalculator).
_circuit_dag (DAGCircuit | None):
DAG representation of the circuit, used for structural traversal
when computing quantum-specific metrics.
_torch_available (bool):
Indicates whether PyTorch is available for metrics that rely on
differentiable simulation or gradient-based optimization.
_max_steps (int):
Maximum number of optimization steps used in iterative or
differentiable metrics (e.g., magic or sensitivity estimation).
_lr (float):
Learning rate used for gradient-based optimization of certain metrics.
_device (str):
Device specification ("cpu" or "cuda") for metrics that may leverage
PyTorch computation.
_use_trace_norm (bool):
Whether to use the trace norm when computing specific quantum
sensitivity or distance-based metrics.
"""
[docs]
def __init__(self, circuit: QuantumCircuit):
"""
Inicializa el calculador de métricas cuánticas específicas.
"""
super().__init__(circuit)
self._circuit_dag = circuit_to_dag(circuit) if circuit else None
self._ensure_schemas_available()
self._torch_available = TORCH_AVAILABLE
# Parámetros de optimización para métricas diferenciales
self._max_steps = 300
self._lr = 0.05
self._device = "cpu"
self._use_trace_norm = False
def _remove_measurements(self, circuit):
qc_unitary = QuantumCircuit(circuit.num_qubits)
for ci in circuit.data:
instr = ci.operation
qargs = ci.qubits
if instr.name not in ["measure", "barrier", "reset"]:
qc_unitary.append(instr, qargs)
return qc_unitary
def _make_pauli_x_on_n(self, n_qubits: int, target: int) -> np.ndarray:
pauli_x = np.array([[0, 1], [1, 0]], dtype=complex)
mats = [np.eye(2, dtype=complex)] * n_qubits
mats[target] = pauli_x
out = mats[0]
for m in mats[1:]:
out = np.kron(out, m)
return out
def _frobenius_norm(self, mat):
return torch.sqrt(torch.sum(torch.abs(mat) ** 2))
def _trace_norm(self, mat):
sv = torch.linalg.svdvals(mat) # pylint: disable=not-callable
return torch.sum(sv)
# --- Magic ---
def _calculate_magic(self) -> float:
if not self._torch_available:
print(
"Warning: Magic metric requires PyTorch. Install torch>=1.12.0 to enable this metric."
)
return 0.0
try:
return self._magic_metric()
except Exception as e:
print(f"Warning: Magic calculation failed: {e}")
return 0.0
def _magic_metric(self) -> float:
circuit = self._remove_measurements(self.circuit)
unitary = Operator(circuit).data
unitary_t = torch.tensor(unitary, dtype=torch.complex64, device=self._device)
return self._magic_optimize(unitary_t)
def _magic_proxy(self, rho_out):
off_diag = rho_out - torch.diag(torch.diag(rho_out))
return torch.sum(torch.abs(torch.imag(off_diag)))
def _magic_optimize(self, unitary):
dim = unitary.shape[0]
x = torch.randn(dim, requires_grad=True, device=self._device)
optimizer = torch.optim.Adam([x], lr=self._lr)
best_val = 0.0
for _ in range(self._max_steps):
p = torch.softmax(x, dim=0)
rho = torch.diag(p).to(torch.complex64)
rho_out = unitary @ rho @ torch.conj(unitary.T)
magic_val = self._magic_proxy(rho_out)
loss = -magic_val
optimizer.zero_grad()
loss.backward()
optimizer.step()
val = float(magic_val.detach().cpu().item())
best_val = max(best_val, val)
return best_val
# --- Coherence ---
def _calculate_coherence(self) -> float:
if not self._torch_available:
print(
"Warning: Coherence metric requires PyTorch. Install torch>=1.12.0 to enable this metric."
)
return 0.0
try:
return self._coherence_metric()
except Exception as e:
print(f"Warning: Coherence calculation failed: {e}")
return 0.0
def _coherence_metric(self) -> float:
circuit = self._remove_measurements(self.circuit)
unitary = Operator(circuit).data
unitary_t = torch.tensor(unitary, dtype=torch.complex64, device=self._device)
return self._coherence_optimize(unitary_t)
def _coherence_l1(self, rho):
off_diag = rho - torch.diag(torch.diag(rho))
return torch.sum(torch.abs(off_diag))
def _coherence_optimize(self, unitary):
dim = unitary.shape[0]
x = torch.randn(dim, requires_grad=True, device=self._device)
optimizer = torch.optim.Adam([x], lr=self._lr)
for _ in range(self._max_steps):
p = torch.softmax(x, dim=0)
rho = torch.diag(p).to(torch.complex64)
rho_out = unitary @ rho @ torch.conj(unitary.T)
coherence = self._coherence_l1(rho_out)
loss = -coherence
optimizer.zero_grad()
loss.backward()
optimizer.step()
return float(-loss.item())
# --- Sensitivity ---
def _generate_pauli_labels(self, n_qubits: int, w_max: int) -> List[Tuple[str, ...]]:
"""Generate Pauli labels of weight ≤ w_max (e.g. ('X','I','Z'))."""
import itertools
labels = []
for w in range(0, w_max + 1):
for positions in itertools.combinations(range(n_qubits), w):
for prod in itertools.product(["X", "Y", "Z"], repeat=w):
label = ["I"] * n_qubits
for pos, sym in zip(positions, prod):
label[pos] = sym
labels.append(tuple(label))
return labels
def _pauli_label_to_matrix(self, label: Tuple[str, ...]) -> np.ndarray:
"""Convert a tuple of ('I','X','Y','Z') to its matrix via kron."""
mats = [PAULI_SINGLE[s] for s in label]
out = mats[0]
for m in mats[1:]:
out = np.kron(out, m)
return out
def _influence_from_coeffs(
self, coeffs: "torch.Tensor", labels: List[Tuple[str, ...]], n_qubits: int, dim_factor: int
) -> "torch.Tensor":
"""Compute total influence as in Bu et al. but on restricted basis."""
q_a = (coeffs.abs() ** 2) * float(dim_factor)
per_qubit = torch.zeros(n_qubits, dtype=torch.float32, device=self._device)
for idx, label in enumerate(labels):
for i, sym in enumerate(label):
if sym != "I":
per_qubit[i] += q_a[idx].real
return torch.sum(per_qubit)
def _pauli_coeffs_restricted(
self, obs_t: "torch.Tensor", pauli_t_list: List["torch.Tensor"], dim_factor: int
) -> "torch.Tensor":
"""Return coefficients c_a = Tr(P_a^† O)/2^n for a restricted list of Paulis."""
coeffs = []
denom = float(dim_factor)
for pauli in pauli_t_list:
coeff = torch.trace(torch.conj(pauli).T @ obs_t) / denom
coeffs.append(coeff)
return torch.stack(coeffs)
def _calculate_sensitivity(self) -> float:
"""Approximate Circuit Sensitivity (CiS) from Bu et al., practical version."""
if not self._torch_available:
print("Warning: Sensitivity metric requires PyTorch.")
return 0.0
# --- Precompute data ---
circuit = self._remove_measurements(self.circuit)
unitary_np = Operator(circuit).data
unitary_t = torch.tensor(unitary_np, dtype=torch.complex64, device=self._device)
dim = unitary_np.shape[0]
n_qubits = int(np.log2(dim))
dim_factor = 2**n_qubits
# Restricted Pauli sets
w_max_obs = 1 # parametrize O as combo of weight-1 Paulis
w_max_eval = 2 # evaluate expansion up to weight 2
labels_obs = self._generate_pauli_labels(n_qubits, w_max_obs)
labels_eval = self._generate_pauli_labels(n_qubits, w_max_eval)
# Precompute torch Paulis
def np_to_torch(mat_np):
real = torch.tensor(mat_np.real, dtype=torch.float32, device=self._device)
imag = torch.tensor(mat_np.imag, dtype=torch.float32, device=self._device)
return torch.complex(real, imag)
pauli_eval_t = [np_to_torch(self._pauli_label_to_matrix(lbl)) for lbl in labels_eval]
pauli_obs_t = [np_to_torch(self._pauli_label_to_matrix(lbl)) for lbl in labels_obs]
# Parameters (real coefficients α_j for O)
m = len(labels_obs)
params = torch.randn(m, device=self._device, dtype=torch.float32) * 0.1
params.requires_grad_(True)
opt = torch.optim.Adam([params], lr=self._lr)
best_val = 0.0
for _ in range(self._max_steps):
opt.zero_grad()
# Construct O = sum α_j P_j
obs_t = torch.zeros((dim, dim), dtype=torch.complex64, device=self._device)
for a, alpha in enumerate(params):
obs_t = obs_t + alpha * pauli_obs_t[a]
# Normalize Hilbert–Schmidt
hs_norm = torch.sqrt(torch.real(torch.trace(torch.conj(obs_t).T @ obs_t)))
obs_t = obs_t / (hs_norm + 1e-12)
# Influence before
coeffs_obs = self._pauli_coeffs_restricted(obs_t, pauli_eval_t, dim_factor)
influence_obs = self._influence_from_coeffs(
coeffs_obs, labels_eval, n_qubits, dim_factor
)
# After conjugation
obs_prime = unitary_t @ obs_t @ torch.conj(unitary_t).T
coeffs_obs_prime = self._pauli_coeffs_restricted(obs_prime, pauli_eval_t, dim_factor)
influence_obs_prime = self._influence_from_coeffs(
coeffs_obs_prime, labels_eval, n_qubits, dim_factor
)
val = torch.abs(influence_obs_prime - influence_obs)
loss = -val
loss.backward()
opt.step()
current_val = float(val.detach().cpu().item())
best_val = max(best_val, current_val)
return float(best_val)
def _get_metric_type(self) -> MetricsType:
"""
Get the type of this metric.
Returns:
MetricsType: PRE_RUNTIME (can be calculated without execution)
"""
return MetricsType.PRE_RUNTIME
def _get_metric_id(self) -> MetricsId:
"""
Get the ID of this metric.
Returns:
MetricsId: QUANTUM_SPECIFIC
"""
return MetricsId.QUANTUM_SPECIFIC
[docs]
def is_ready(self) -> bool:
"""
Check if the metric is ready to be calculated.
Returns:
bool: True if the circuit is available, False otherwise
"""
return self.circuit is not None
[docs]
def get_metrics(self) -> QuantumSpecificMetricsSchema:
"""
Calculate and return quantum specific metrics.
Returns:
QuantumSpecificMetricsSchema: Validated schema with all metrics
Raises:
ImportError: If schemas are not available
"""
if not SCHEMAS_AVAILABLE:
raise ImportError("QuantumSpecificMetricsSchema is not available")
# Calculate %SpposQ metric
spposq_ratio = self._calculate_spposq_ratio()
# Calculate Magic metric
magic_value = self._calculate_magic()
# Calculate Coherence metric
coherence_value = self._calculate_coherence()
# Calculate Sensitivity metric
sensitivity_value = self._calculate_sensitivity()
# Calculate Entanglement-Ratio metric
entanglement_ratio = self._calculate_entanglement_ratio()
return QuantumSpecificMetricsSchema(
spposq_ratio=spposq_ratio,
magic=magic_value,
coherence=coherence_value,
sensitivity=sensitivity_value,
entanglement_ratio=entanglement_ratio,
)
def _calculate_spposq_ratio(self) -> float:
"""
Calculate %SpposQ: Ratio of qubits with a Hadamard gate as initial gate.
Returns:
float: Ratio of qubits that start with a Hadamard gate
"""
if not self.circuit or self.circuit.num_qubits == 0:
return 0.0
qubits_with_hadamard_start = 0
total_qubits = self.circuit.num_qubits
# For each qubit, check if the first operation is a Hadamard gate
active_qubits = 0
for qubit_index in range(total_qubits):
qubit = self.circuit.qubits[qubit_index]
for instruction in self.circuit.data:
if qubit in instruction.qubits:
active_qubits += 1
gate_name = instruction.operation.name.lower()
if gate_name == "h":
qubits_with_hadamard_start += 1
break # first operation found
if active_qubits == 0:
return 0.0
return qubits_with_hadamard_start / active_qubits
def _calculate_entanglement_ratio(self) -> float:
"""
Calculate Entanglement-Ratio: Ratio of two-qubit interactions to total operations.
Formula: E = ne/ng
where:
- ne: number of two-qubit interactions
- ng: total number of gate operations
Returns:
float: Entanglement ratio
"""
if not self.circuit:
return 0.0
two_qubit_gates = 0
total_gate_operations = 0
for instruction in self.circuit.data:
gate_name = instruction.operation.name.lower()
# Skip measurement, barrier, and reset operations
if gate_name in {"measure", "barrier", "reset"}:
continue
total_gate_operations += 1
# Check if it's a two-qubit gate
if gate_name in TWO_QUBIT_GATES:
two_qubit_gates += 1
elif len(instruction.qubits) >= 2:
# If gate operates on 2 or more qubits, count as two-qubit interaction
two_qubit_gates += 1
if total_gate_operations == 0:
return 0.0
return two_qubit_gates / total_gate_operations
def _ensure_schemas_available(self):
"""
Ensure that the required schemas are available.
Raises:
ImportError: If schemas are not available
"""
if not SCHEMAS_AVAILABLE:
raise ImportError(
"QuantumSpecificMetricsSchema is not available. "
"Please ensure that the schemas module is properly imported."
)