Source code for mitiq.calibration.calibrator

# Copyright (C) Unitary Fund
#
# This source code is licensed under the GPL license (v3) found in the
# LICENSE file in the root directory of this source tree.

import warnings
from enum import Enum
from operator import itemgetter
from typing import Callable, Dict, List, Optional, Sequence, Tuple, Union, cast

import cirq
import numpy as np
import numpy.typing as npt
from tabulate import tabulate

from mitiq import (
    QPROGRAM,
    Executor,
    MeasurementResult,
    Observable,
    QuantumResult,
)
from mitiq.calibration.settings import (
    ZNE_SETTINGS,
    BenchmarkProblem,
    Settings,
    Strategy,
)
from mitiq.interface import convert_from_mitiq


[docs] class MissingResultsError(Exception): pass
[docs] class OutputForm(str, Enum): flat = "flat" cartesian = "cartesian"
[docs] class ExperimentResults: """Class to store calibration experiment data, and provide helper methods for computing results based on it.""" def __init__( self, strategies: List[Strategy], problems: List[BenchmarkProblem] ) -> None: self.strategies = strategies self.problems = problems self.num_strategies = len(strategies) self.num_problems = len(problems) self.reset_data()
[docs] def add_result( self, strategy: Strategy, problem: BenchmarkProblem, *, ideal_val: float, noisy_val: float, mitigated_val: float, ) -> None: """Add a single result from a (Strategy, BenchmarkProblem) pair and store the results.""" self.mitigated[strategy.id, problem.id] = mitigated_val self.noisy[strategy.id, problem.id] = noisy_val self.ideal[strategy.id, problem.id] = ideal_val
@staticmethod def _performance_str(noisy_error: float, mitigated_error: float) -> str: """Get human readable performance representaion.""" return ( f"{'✔' if mitigated_error < noisy_error else '✘'}\n" f"Noisy error: {round(noisy_error, 4)}\n" f"Mitigated error: {round(mitigated_error, 4)}\n" f"Improvement factor: {round(noisy_error / mitigated_error, 4)}" ) def _get_errors( self, strategy_id: int, problem_id: int ) -> Tuple[float, float]: """Get errors for a given strategy/problem combination. Returns: A tuple comprising: - absolute value of the noisy error - absolute value of the mitigated error """ mitigated = self.mitigated[strategy_id, problem_id] noisy = self.noisy[strategy_id, problem_id] ideal = self.ideal[strategy_id, problem_id] mitigated_error = abs(ideal - mitigated) noisy_error = abs(ideal - noisy) return noisy_error, mitigated_error
[docs] def log_results_flat(self) -> None: """Prints calibration results in the following form ┌──────────────────────────┬──────────────────────────────┬────────────────────────────┐ │ benchmark │ strategy │ performance │ ├──────────────────────────┼──────────────────────────────┼────────────────────────────┤ │ Type: rb │ Technique: ZNE │ ✔ │ │ Num qubits: 2 │ Factory: Richardson │ Noisy error: 0.101 │ │ Circuit depth: 323 │ Scale factors: 1.0, 3.0, 5.0 │ Mitigated error: 0.0294 │ │ Two qubit gate count: 77 │ Scale method: fold_global │ Improvement factor: 3.4398 │ ├──────────────────────────┼──────────────────────────────┼────────────────────────────┤ │ Type: rb │ Technique: ZNE │ ✔ │ │ Num qubits: 2 │ Factory: Richardson │ Noisy error: 0.101 │ │ Circuit depth: 323 │ Scale factors: 1.0, 2.0, 3.0 │ Mitigated error: 0.0501 │ │ Two qubit gate count: 77 │ Scale method: fold_global │ Improvement factor: 2.016 │ ├──────────────────────────┼──────────────────────────────┼────────────────────────────┤ │ Type: ghz │ Technique: ZNE │ ✔ │ │ Num qubits: 2 │ Factory: Richardson │ Noisy error: 0.0128 │ │ Circuit depth: 2 │ Scale factors: 1.0, 2.0, 3.0 │ Mitigated error: 0.0082 │ │ Two qubit gate count: 1 │ Scale method: fold_global │ Improvement factor: 1.561 │ ├──────────────────────────┼──────────────────────────────┼────────────────────────────┤ │ Type: ghz │ Technique: ZNE │ ✘ │ │ Num qubits: 2 │ Factory: Richardson │ Noisy error: 0.0128 │ │ Circuit depth: 2 │ Scale factors: 1.0, 3.0, 5.0 │ Mitigated error: 0.0137 │ │ Two qubit gate count: 1 │ Scale method: fold_global │ Improvement factor: 0.9369 │ └──────────────────────────┴──────────────────────────────┴────────────────────────────┘ """ # noqa: E501 table: List[List[Union[str, float]]] = [] headers: List[str] = ["benchmark", "strategy", "performance"] for problem in self.problems: row_group: List[List[Union[str, float]]] = [] for strategy in self.strategies: nerr, merr = self._get_errors(strategy.id, problem.id) row_group.append( [ str(problem), str(strategy), self._performance_str(nerr, merr), # this is only for sorting # removed after sorting merr - nerr, ] ) row_group.sort(key=itemgetter(-1)) table.extend([r[:-1] for r in row_group]) return print(tabulate(table, headers, tablefmt="simple_grid"))
[docs] def log_results_cartesian(self) -> None: """Prints calibration results in the following form ┌──────────────────────────────┬────────────────────────────┬────────────────────────────┐ │ strategy\benchmark │ Type: rb │ Type: ghz │ │ │ Num qubits: 2 │ Num qubits: 2 │ │ │ Circuit depth: 337 │ Circuit depth: 2 │ │ │ Two qubit gate count: 80 │ Two qubit gate count: 1 │ ├──────────────────────────────┼────────────────────────────┼────────────────────────────┤ │ Technique: ZNE │ ✔ │ ✘ │ │ Factory: Richardson │ Noisy error: 0.1128 │ Noisy error: 0.0117 │ │ Scale factors: 1.0, 2.0, 3.0 │ Mitigated error: 0.0501 │ Mitigated error: 0.0439 │ │ Scale method: fold_global │ Improvement factor: 2.2515 │ Improvement factor: 0.2665 │ ├──────────────────────────────┼────────────────────────────┼────────────────────────────┤ │ Technique: ZNE │ ✔ │ ✘ │ │ Factory: Richardson │ Noisy error: 0.1128 │ Noisy error: 0.0117 │ │ Scale factors: 1.0, 3.0, 5.0 │ Mitigated error: 0.0408 │ Mitigated error: 0.0171 │ │ Scale method: fold_global │ Improvement factor: 2.7672 │ Improvement factor: 0.6852 │ └──────────────────────────────┴────────────────────────────┴────────────────────────────┘ """ # noqa: E501 table: List[List[str]] = [] headers: List[str] = ["strategy\\benchmark"] for problem in self.problems: headers.append(str(problem)) for strategy in self.strategies: row: List[str] = [str(strategy)] for problem in self.problems: nerr, merr = self._get_errors(strategy.id, problem.id) row.append(self._performance_str(nerr, merr)) table.append(row) return print(tabulate(table, headers, tablefmt="simple_grid"))
[docs] def is_missing_data(self) -> bool: """Method to check if there is any missing data that was expected from the calibration experiments.""" return np.isnan(self.mitigated + self.noisy + self.ideal).any()
[docs] def ensure_full(self) -> None: """Check to ensure all expected data is collected. All mitigated, noisy and ideal values must be nonempty for this to pass and return True.""" if self.is_missing_data(): raise MissingResultsError( "There are missing results from the expected calibration " "experiments. Please try running the experiments again with " "the `run` function." )
[docs] def squared_errors(self) -> npt.NDArray[np.float32]: """Returns an array of squared errors, one for each (strategy, problem) pair.""" return (self.ideal - self.mitigated) ** 2
[docs] def best_strategy_id(self) -> int: """Returns the stategy id that corresponds to the strategy that maintained the smallest error across all ``BenchmarkProblem`` instances.""" errors = self.squared_errors() strategy_errors = np.sum(errors, axis=1) strategy_id = int(np.argmin(strategy_errors)) return strategy_id
[docs] def reset_data(self) -> None: """Reset all experiment result data using NaN values.""" self.mitigated = np.full( (self.num_strategies, self.num_problems), np.nan ) self.noisy = np.full((self.num_strategies, self.num_problems), np.nan) self.ideal = np.full((self.num_strategies, self.num_problems), np.nan)
[docs] class Calibrator: """An object used to orchestrate experiments for calibrating optimal error mitigation strategies. Args: executor: An unmitigated executor returning a :class:`.MeasurementResult`. settings: A ``Settings`` object which specifies the type and amount of circuits/error mitigation methods to run. frontend: The executor frontend as a string. For a list of supported frontends see ``mitiq.SUPPORTED_PROGRAM_TYPES.keys()``, ideal_executor: An optional simulated executor returning the ideal :class:`.MeasurementResult` without noise. """ def __init__( self, executor: Union[Executor, Callable[[QPROGRAM], QuantumResult]], *, frontend: str, settings: Settings = ZNE_SETTINGS, ideal_executor: Union[ Executor, Callable[[QPROGRAM], QuantumResult], None ] = None, ): self.executor = ( executor if isinstance(executor, Executor) else Executor(executor) ) self.ideal_executor = ( Executor(ideal_executor) if ideal_executor and not isinstance(ideal_executor, Executor) else None ) self.settings = settings self.problems = settings.make_problems() self.strategies = settings.make_strategies() self.results = ExperimentResults( strategies=self.strategies, problems=self.problems ) # Build an executor of Cirq circuits def cirq_execute( circuits: Sequence[cirq.Circuit], ) -> Sequence[MeasurementResult]: q_programs = [convert_from_mitiq(c, frontend) for c in circuits] results = cast( Sequence[MeasurementResult], self.executor.run(q_programs) ) return results self._cirq_executor = Executor(cirq_execute) # type: ignore [arg-type] @property def cirq_executor(self) -> Executor: """Returns an executor which is able to run Cirq circuits by converting them and calling self.executor. Args: executor: Executor which takes as input QPROGRAM circuits. Returns: Executor which takes as input a Cirq circuits. """ return self._cirq_executor
[docs] def get_cost(self) -> Dict[str, int]: """Returns the expected number of noisy and ideal expectation values required for calibration. Returns: A summary of the number of circuits to be run. """ num_circuits = len(self.problems) num_options = sum( strategy.num_circuits_required() for strategy in self.strategies ) noisy = num_circuits * (num_options + 1) ideal = 0 # TODO: ideal executor is currently unused return { "noisy_executions": noisy, "ideal_executions": ideal, }
[docs] def run(self, log: Optional[OutputForm] = None) -> None: """Runs all the circuits required for calibration.""" if not self.results.is_missing_data(): self.results.reset_data() for problem in self.problems: # Benchmark circuits have no measurements, so we append them. circuit = problem.circuit.copy() circuit.append(cirq.measure(circuit.all_qubits())) bitstring_to_measure = problem.most_likely_bitstring() expval_executor = convert_to_expval_executor( self.cirq_executor, bitstring_to_measure ) noisy_value = expval_executor.evaluate(circuit)[0] for strategy in self.strategies: with warnings.catch_warnings(): warnings.simplefilter("ignore", UserWarning) mitigated_value = strategy.mitigation_function( circuit, expval_executor ) self.results.add_result( strategy, problem, ideal_val=problem.largest_probability(), noisy_val=noisy_value, mitigated_val=mitigated_value, ) self.results.ensure_full() if log is not None: if log == OutputForm.flat: self.results.log_results_flat() elif log == OutputForm.cartesian: self.results.log_results_cartesian() else: raise ValueError( "log parameter must be one of: " f"{', '.join(OutputForm._member_names_)}" )
[docs] def best_strategy(self) -> Strategy: """Finds the best strategy by using the parameters that had the smallest error. Args: results: Calibration experiment results. Obtained by first running :func:`run`. Returns: A single :class:`Strategy` object specifying the technique and parameters that performed best. """ self.results.ensure_full() strategy_id = self.results.best_strategy_id() return self.settings.get_strategy(strategy_id)
[docs] def execute_with_mitigation( self, circuit: QPROGRAM, expval_executor: Union[Executor, Callable[[QPROGRAM], QuantumResult]], observable: Optional[Observable] = None, ) -> Union[QuantumResult, None]: """See :func:`execute_with_mitigation` for signature and details.""" return execute_with_mitigation( circuit, expval_executor, observable, calibrator=self )
[docs] def convert_to_expval_executor(executor: Executor, bitstring: str) -> Executor: """Constructs a new executor returning an expectation value given by the probability that the circuit outputs the most likely state according to the ideal distribution. Args: executor: Executor which returns a :class:`.MeasurementResult` (bitstrings). bitstring: The bitstring to measure the probability of. Defaults to ground state bitstring "00...0". Returns: A tuple containing an executor returning expectation values and, the most likely bitstring, according to the passed ``distribution`` """ def expval_executor(circuit: cirq.Circuit) -> float: circuit_with_meas = circuit.copy() if not cirq.is_measurement(circuit_with_meas): circuit_with_meas.append( cirq.measure(circuit_with_meas.all_qubits()) ) raw = cast(MeasurementResult, executor.run([circuit_with_meas])[0]) distribution = raw.prob_distribution() return distribution.get(bitstring, 0.0) return Executor(expval_executor) # type: ignore [arg-type]
[docs] def execute_with_mitigation( circuit: QPROGRAM, executor: Union[Executor, Callable[[QPROGRAM], QuantumResult]], observable: Optional[Observable] = None, *, calibrator: Calibrator, ) -> Union[QuantumResult, None]: """Estimates the error-mitigated expectation value associated to the input circuit, via the application of the best mitigation strategy, as determined by calibration. Args: circuit: The input circuit to execute. executor: A Mitiq executor that executes a circuit and returns the unmitigated ``QuantumResult`` (e.g. an expectation value). observable: Observable to compute the expectation value of. If ``None``, the ``executor`` must return an expectation value. Otherwise, the ``QuantumResult`` returned by ``executor`` is used to compute the expectation of the observable. calibrator: ``Calibrator`` object with which to determine the error mitigation strategy to execute the circuit. Returns: The error mitigated expectation expectation value. """ if calibrator.results.is_missing_data(): cost = calibrator.get_cost() answer = input( "Calibration experiments have not yet been run. You can run the " "experiments manually by calling `calibrator.run()`, or they can " f"be run now. The potential cost is:\n{cost}\n" "Would you like the experiments to be run automatically? (yes/no)" ) if answer.lower() == "yes": calibrator.run() else: return None strategy = calibrator.best_strategy() em_func = strategy.mitigation_function return em_func(circuit, executor=executor, observable=observable)