diff --git a/docs/apidocs/index.rst b/docs/apidocs/index.rst index b06897e4f8..b23965c685 100644 --- a/docs/apidocs/index.rst +++ b/docs/apidocs/index.rst @@ -30,3 +30,4 @@ Experiment Modules mod_randomized_benchmarking mod_tomography mod_quantum_volume + mod_hamiltonian diff --git a/docs/apidocs/mod_hamiltonian.rst b/docs/apidocs/mod_hamiltonian.rst new file mode 100644 index 0000000000..9fbf5e00cd --- /dev/null +++ b/docs/apidocs/mod_hamiltonian.rst @@ -0,0 +1,6 @@ +.. _qiskit-experiments-hamiltonian: + +.. automodule:: qiskit_experiments.library.hamiltonian + :no-members: + :no-inherited-members: + :no-special-members: diff --git a/qiskit_experiments/framework/composite/__init__.py b/qiskit_experiments/framework/composite/__init__.py index d308f3f38c..fcffda439e 100644 --- a/qiskit_experiments/framework/composite/__init__.py +++ b/qiskit_experiments/framework/composite/__init__.py @@ -18,3 +18,5 @@ # Composite experiment classes from .parallel_experiment import ParallelExperiment from .batch_experiment import BatchExperiment + +from .composite_experiment import sync_experiment_options, sync_transpile_options diff --git a/qiskit_experiments/framework/composite/composite_experiment.py b/qiskit_experiments/framework/composite/composite_experiment.py index c9dc7ec339..24a713ecfc 100644 --- a/qiskit_experiments/framework/composite/composite_experiment.py +++ b/qiskit_experiments/framework/composite/composite_experiment.py @@ -13,8 +13,9 @@ Composite Experiment abstract base class. """ -from typing import List, Sequence, Optional, Union +from typing import List, Sequence, Optional, Union, Type from abc import abstractmethod +import functools import warnings from qiskit.providers.backend import Backend from qiskit_experiments.framework import BaseExperiment, ExperimentData @@ -140,3 +141,75 @@ def _postprocess_transpiled_circuits(self, circuits, **run_options): for expr in self._experiments: if not isinstance(expr, CompositeExperiment): expr._postprocess_transpiled_circuits(circuits, **run_options) + + +def sync_transpile_options( + composite_cls: Type[CompositeExperiment], +) -> Type[CompositeExperiment]: + """A class decorator that overrides the transpile option setter method. + + This method overrides the behavior of :meth:`set_transpile_options` method. + The option values set to the composite instance + will be propagated through all component experiments. + + Args: + composite_cls: CompositeExperiment subclass to decorate. + + Returns: + Composite experiment that implements option synchronization. + + Raises: + TypeError: When class is not subclass of :class:`CompositeExperiment` + """ + if not issubclass(composite_cls, CompositeExperiment): + raise TypeError("Class is not composite experiment. Cannot override method.") + + options_setter = getattr(composite_cls, "set_transpile_options") + + @functools.wraps(options_setter) + def sync_opts(instance, **fields): + options_setter(instance, **fields) + # set the same options to component experiments + for comp in instance.component_experiment(): + comp.set_transpile_options(**fields) + + # override set method + setattr(composite_cls, "set_transpile_options", sync_opts) + + return composite_cls + + +def sync_experiment_options( + composite_cls: Type[CompositeExperiment], +) -> Type[CompositeExperiment]: + """A class decorator that overrides the experiment option setter method. + + This method overrides the behavior of :meth:`set_experiment_options` method. + The option values set to the composite instance + will be propagated through all component experiments. + + Args: + composite_cls: CompositeExperiment subclass to decorate. + + Returns: + Composite experiment that implements option synchronization. + + Raises: + TypeError: When class is not subclass of :class:`CompositeExperiment` + """ + if not issubclass(composite_cls, CompositeExperiment): + raise TypeError("Class is not composite experiment. Cannot override method.") + + options_setter = getattr(composite_cls, "set_experiment_options") + + @functools.wraps(options_setter) + def sync_opts(instance, **fields): + options_setter(instance, **fields) + # set the same options to component experiments + for comp in instance.component_experiment(): + comp.set_experiment_options(**fields) + + # override set method + setattr(composite_cls, "set_experiment_options", sync_opts) + + return composite_cls diff --git a/qiskit_experiments/library/__init__.py b/qiskit_experiments/library/__init__.py index 8704430d25..e9a36700da 100644 --- a/qiskit_experiments/library/__init__.py +++ b/qiskit_experiments/library/__init__.py @@ -73,6 +73,10 @@ ~characterization.FineFrequency ~characterization.ReadoutAngle ~characterization.ResonatorSpectroscopy + ~hamiltonian.ZXHeat + ~hamiltonian.ZX90HeatXError + ~hamiltonian.ZX90HeatYError + ~hamiltonian.ZX90HeatZError .. _calibration: @@ -150,6 +154,7 @@ class instance to manage parameters and pulse schedules. from .tomography import StateTomography, ProcessTomography from .quantum_volume import QuantumVolume from .mitigation import ReadoutMitigationExperiment +from .hamiltonian import ZXHeat, ZX90HeatXError, ZX90HeatYError, ZX90HeatZError # Experiment Sub-modules from . import calibration @@ -157,3 +162,4 @@ class instance to manage parameters and pulse schedules. from . import randomized_benchmarking from . import tomography from . import quantum_volume +from . import hamiltonian diff --git a/qiskit_experiments/library/hamiltonian/__init__.py b/qiskit_experiments/library/hamiltonian/__init__.py new file mode 100644 index 0000000000..caf110a25e --- /dev/null +++ b/qiskit_experiments/library/hamiltonian/__init__.py @@ -0,0 +1,65 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2022. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. +""" +=============================================================================================== +Hamiltonian Characterization Experiments (:mod:`qiskit_experiments.library.hamiltonian`) +=============================================================================================== + +.. currentmodule:: qiskit_experiments.library.hamiltonian + +This module provides a set of experiments to characterize qubit Hamiltonians. + +HEAT Experiments +================ + +HEAT stands for `Hamiltonian Error Amplifying Tomography` which amplifies the +dynamics of an entangling gate along a specified axis of the target qubit. Here, +errors are typically amplified by repeating a sequence of gates which results in +a ping-pong pattern when measuring the qubit population. + +HEAT for ZX Hamiltonian +----------------------- + +.. autosummary:: + :toctree: ../stubs/ + :template: autosummary/experiment.rst + + ZXHeat + ZX90HeatXError + ZX90HeatYError + ZX90HeatZError + +HEAT Analysis +------------- + +.. autosummary:: + :toctree: ../stubs/ + :template: autosummary/analysis.rst + + HeatElementAnalysis + HeatAnalysis + +HEAT Base Classes +----------------- + +.. autosummary:: + :toctree: ../stubs/ + :template: autosummary/experiment.rst + + HeatElement + + +""" + +from .heat_base import HeatElement +from .heat_zx import ZXHeat, ZX90HeatXError, ZX90HeatYError, ZX90HeatZError +from .heat_analysis import HeatElementAnalysis, HeatAnalysis diff --git a/qiskit_experiments/library/hamiltonian/heat_analysis.py b/qiskit_experiments/library/hamiltonian/heat_analysis.py new file mode 100644 index 0000000000..08dde8097b --- /dev/null +++ b/qiskit_experiments/library/hamiltonian/heat_analysis.py @@ -0,0 +1,158 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2021. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. +""" +Analysis for HEAT experiments. +""" + +from typing import Tuple + +import numpy as np + +from qiskit_experiments.curve_analysis import ErrorAmplificationAnalysis, ParameterRepr +from qiskit_experiments.exceptions import AnalysisError +from qiskit_experiments.framework import ( + CompositeAnalysis, + ExperimentData, + AnalysisResultData, + Options, +) +from qiskit_experiments.data_processing import DataProcessor, Probability + + +class HeatElementAnalysis(ErrorAmplificationAnalysis): + """An analysis class for HEAT experiment to define the fixed parameters. + + # section: note + + This analysis assumes the experiment measures only single qubit + regardless of the number of physical qubits used in the experiment. + + # section: overview + + This is standard error amplification analysis. + + # section: see_also + qiskit_experiments.curve_analysis.ErrorAmplificationAnalysis + """ + + __fixed_parameters__ = ["angle_per_gate", "phase_offset", "amp"] + + @classmethod + def _default_options(cls) -> Options: + """Default analysis options.""" + options = super()._default_options() + options.angle_per_gate = np.pi + options.phase_offset = np.pi / 2 + options.amp = 1.0 + options.data_processor = DataProcessor(input_key="counts", data_actions=[Probability("1")]) + + return options + + +class HeatAnalysis(CompositeAnalysis): + r"""A composite error amplification analysis to get unitary error coefficients. + + # section: fit_model + + Heat experiment amplifies the dynamics of the entangling gate along the + experiment-specific error axis on the target qubit Bloch sphere. + This analysis takes two error amplification experiment results performed with + different states of the control qubit to distinguish the contribution + of local term (such as IX) from non-local term (such as ZX). + + This analysis takes a set of `d_theta` parameters from child error amplification results + which might be represented by a unique name in the child experiment data. + With these fit parameters, two Hamiltonian coefficients will be computed as + + .. math:: + + A_{I\beta} = \frac{1}{2}\left( d\theta_{\beta 0} + d\theta_{\beta 1} \right) \\ + + A_{Z\beta} = \frac{1}{2}\left( d\theta_{\beta 0} - d\theta_{\beta 1} \right) + + where, :math:`\beta \in [X, Y, Z]` is a single-qubit Pauli term, and + :math:`d\theta_{\beta k}` is an angle error ``d_theta`` extracted from the HEAT experiment + with the control qubit in state :math:`|k\rangle \in [|0\rangle, |1\rangle]`. + + # section: see_also + qiskit_experiments.library.hamiltonian.HeatElementAnalysis + qiskit_experiments.curve_analysis.ErrorAmplificationAnalysis + + """ + + def __init__( + self, + fit_params: Tuple[str, str], + out_params: Tuple[str, str], + ): + """Create new HEAT analysis. + + Args: + fit_params: Name of error parameters for each amplification sequence. + out_params: Name of Hamiltonian coefficients. + + Raises: + AnalysisError: When size of ``fit_params`` or ``out_params`` are not 2. + """ + if len(fit_params) != 2: + raise AnalysisError( + f"{self.__class__.__name__} assumes two fit parameters extracted from " + "a set of experiments with different control qubit state input. " + f"{len(fit_params)} input parameter names are specified." + ) + + if len(out_params) != 2: + raise AnalysisError( + f"{self.__class__.__name__} assumes two output parameters computed with " + "a set of experiment results with different control qubit state input. " + f"{len(out_params)} output parameter names are specified." + ) + + analyses = [] + for fit_parm in fit_params: + sub_analysis = HeatElementAnalysis() + sub_analysis.set_options(result_parameters=[ParameterRepr("d_theta", fit_parm, "rad")]) + analyses.append(sub_analysis) + + super().__init__(analyses=analyses) + + self._fit_params = fit_params + self._out_params = out_params + + def _run_analysis(self, experiment_data: ExperimentData): + + # wait for child experiments to complete + super()._run_analysis(experiment_data) + + # extract d_theta parameters + fit_results = [] + for i, pname in enumerate(self._fit_params): + fit_results.append(experiment_data.child_data(i).analysis_results(pname)) + + # Check data quality + is_good_quality = all(r.quality == "good" for r in fit_results) + + estimate_ib = AnalysisResultData( + name=self._out_params[0], + value=(fit_results[0].value + fit_results[1].value) / 2, + quality="good" if is_good_quality else "bad", + extra={"unit": "rad"}, + ) + + estimate_zb = AnalysisResultData( + name=self._out_params[1], + value=(fit_results[0].value - fit_results[1].value) / 2, + quality="good" if is_good_quality else "bad", + extra={"unit": "rad"}, + ) + + return [estimate_ib, estimate_zb], [] diff --git a/qiskit_experiments/library/hamiltonian/heat_base.py b/qiskit_experiments/library/hamiltonian/heat_base.py new file mode 100644 index 0000000000..81b8f84f57 --- /dev/null +++ b/qiskit_experiments/library/hamiltonian/heat_base.py @@ -0,0 +1,156 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2022. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. +""" +Base Class for general Hamiltonian Error Amplifying Tomography experiments. +""" + +from typing import List, Tuple, Optional + +from qiskit import circuit, QuantumCircuit +from qiskit.providers import Backend + +from qiskit_experiments.framework import BaseExperiment, Options +from .heat_analysis import HeatElementAnalysis + + +class HeatElement(BaseExperiment): + """Base class of HEAT experiment elements. + + # section: overview + + Hamiltonian error amplifying tomography (HEAT) is designed to amplify + the dynamics of entangler circuit on a qubit along a specific axis. + + The basic form of HEAT circuit is represented as follows. + + .. parsed-literal:: + + (xN) + ┌───────┐ ░ ┌───────┐┌───────┐ ░ ┌───────┐ + q_0: ┤0 ├─░─┤0 ├┤0 ├─░─┤0 ├─── + │ prep │ ░ │ heat ││ echo │ ░ │ meas │┌─┐ + q_1: ┤1 ├─░─┤1 ├┤1 ├─░─┤1 ├┤M├ + └───────┘ ░ └───────┘└───────┘ ░ └───────┘└╥┘ + c: 1/═══════════════════════════════════════════╩═ + 0 + + The circuit in the middle is repeated ``N`` times to amplify the Hamiltonian + coefficients along a specific axis of the second qubit. The ``prep`` circuit is + carefully chosen based on the generator of the ``heat`` gate under consideration. + The ``echo`` and ``meas`` circuits depend on the axis of the error to amplify. + Only the second qubit is measured. + + The measured qubit population containing the amplified error typically has contributions + from both local (e.g. IZ) and non-local rotations (e.g. ZX). + Thus, multiple error amplification experiments with different control qubit states + are usually combined to resolve these rotation terms. + This experiment just provides a single error amplification sequence, and therefore + you must combine multiple instances instantiated with different ``prep``, ``echo``, + and ``meas`` circuits designed to resolve the different error terms. + This class can be wrapped with hard-coded circuits to define new experiment classes + to provide HEAT experiments for different error axes and Hamiltonians of interest. + + The ``heat`` gate is a custom gate representing the entangling pulse sequence. + One must thus provide its definition through the backend or a custom transpiler + configuration, i.e. with the instruction schedule map. This gate name can be overridden + via the experiment options. + + # section: note + + This class is usually not exposed to end users. + The developer of a new HEAT experiment must design the amplification sequences and + create instances of this class implicitly in the batch experiment. + + # section: analysis_ref + :py:class:`HeatElementAnalysis` + + # section: reference + .. ref_arxiv:: 1 2007.02925 + """ + + def __init__( + self, + qubits: Tuple[int, int], + prep_circ: QuantumCircuit, + echo_circ: QuantumCircuit, + meas_circ: QuantumCircuit, + backend: Optional[Backend] = None, + **kwargs, + ): + """Create new HEAT sub experiment. + + Args: + qubits: Index of control and target qubit, respectively. + prep_circ: A circuit to prepare qubit before the echo sequence. + echo_circ: A circuit to selectively amplify the specific error term. + meas_circ: A circuit to project target qubit onto the basis of interest. + backend: Optional, the backend to run the experiment on. + + Keyword Args: + See :meth:`experiment_options` for details. + """ + super().__init__(qubits=qubits, backend=backend, analysis=HeatElementAnalysis()) + self.set_experiment_options(**kwargs) + + # These are not user configurable options. Be frozen once assigned. + self._prep_circuit = prep_circ + self._echo_circuit = echo_circ + self._meas_circuit = meas_circ + + @classmethod + def _default_experiment_options(cls) -> Options: + """Default experiment options. + + Experiment Options: + repetitions (Sequence[int]): A list of the number of echo repetitions. + heat_gate (Gate): A gate instance representing the entangler sequence. + """ + options = super()._default_experiment_options() + options.repetitions = list(range(21)) + options.heat_gate = circuit.Gate("heat", num_qubits=2, params=[]) + + return options + + @classmethod + def _default_transpile_options(cls) -> Options: + """Default transpile options.""" + options = super()._default_transpile_options() + options.basis_gates = ["sx", "x", "rz", "heat"] + options.optimization_level = 1 + + return options + + def circuits(self) -> List[QuantumCircuit]: + opt = self.experiment_options + + circs = list() + for repetition in opt.repetitions: + circ = circuit.QuantumCircuit(2, 1) + circ.compose(self._prep_circuit, qubits=[0, 1], inplace=True) + circ.barrier() + for _ in range(repetition): + circ.append(self.experiment_options.heat_gate, [0, 1]) + circ.compose(self._echo_circuit, qubits=[0, 1], inplace=True) + circ.barrier() + circ.compose(self._meas_circuit, qubits=[0, 1], inplace=True) + circ.measure(1, 0) + + # add metadata + circ.metadata = { + "experiment_type": self.experiment_type, + "qubits": self.physical_qubits, + "xval": repetition, + } + + circs.append(circ) + + return circs diff --git a/qiskit_experiments/library/hamiltonian/heat_zx.py b/qiskit_experiments/library/hamiltonian/heat_zx.py new file mode 100644 index 0000000000..a96409133f --- /dev/null +++ b/qiskit_experiments/library/hamiltonian/heat_zx.py @@ -0,0 +1,315 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2022. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. +""" +HEAT experiments for ZX Hamiltonian. +""" + +from typing import Tuple, Optional + +import numpy as np +from qiskit.circuit import QuantumCircuit, Gate +from qiskit.providers import Backend + +from qiskit_experiments.framework import Options, composite +from .heat_analysis import HeatAnalysis +from .heat_base import HeatElement + + +@composite.sync_experiment_options +@composite.sync_transpile_options +class ZXHeat(composite.BatchExperiment): + r"""HEAT experiment for the ZX-type entangler. + + # section: overview + This experiment is designed to amplify the error contained in the + ZX-type generator, a typical Hamiltonian implemented + by a cross-resonance drive, which is typically used to create a CNOT gate. + + The experimental circuits are prepared as follows for different + interrogated error axes specified by the experiment parameter ``error_axis``. + + .. parsed-literal:: + +       prep heat    echo   meas + + (xN) + ░ ┌───────┐ ░ + q_0: ────────────░─┤0 ├────────────────────────────░───────── + ┌─────────┐ ░ │ heat │┌────────────────┐┌───────┐ ░ ┌───┐┌─┐ + q_1: ┤ Rα(π/2) ├─░─┤1 ├┤ Rx(-1.0*angle) ├┤ Rβ(π) ├─░─┤ γ ├┤M├ + └─────────┘ ░ └───────┘└────────────────┘└───────┘ ░ └───┘└╥┘ + c: 1/═══════════════════════════════════════════════════════════╩═ + 0 + + (xN) + ┌───┐ ░ ┌───────┐ ░ + q_0: ───┤ X ├────░─┤0 ├───────────────────────░───────── + ┌──┴───┴──┐ ░ │ heat │┌───────────┐┌───────┐ ░ ┌───┐┌─┐ + q_1: ┤ Rα(π/2) ├─░─┤1 ├┤ Rx(angle) ├┤ Rβ(π) ├─░─┤ γ ├┤M├ + └─────────┘ ░ └───────┘└───────────┘└───────┘ ░ └───┘└╥┘ + c: 1/══════════════════════════════════════════════════════╩═ + 0 + + ZX-HEAT experiments are performed by the combination of the two + error amplification experiments shown above, where :math:`\alpha, \beta, \gamma` + depend on the interrogated error axis, namely, + (``X``, ``X``, ``I``), (``Y``, ``Y``, ``I``), (``Y``, ``Z``, ``Rx(-pi/2)``) + for amplifying X, Y, Z axis, respectively. + The circuit in the middle is repeated by N times for the error amplification. + + For example, we amplify the X error in the simplified ``heat`` gate Hamiltonian + + .. math:: + + Ht = \frac{\Omega_{ZX}(t) ZX + \Delta_{IX}(t) IX}{2}. + + From the BCH formula we can derive a unitary evolution of the Hamiltonian + + .. math:: + + U = A_{II} II + A_{IX} IX + A_{ZX} ZX + A_{ZI} ZI. + + Since we have a known control qubit state throughout the echo sequence, + we can compute a partial unitary on the target qubit, namely, + :math:`U_{j} = A_{Ij} I + A_{Xj} X` for the control qubit state :math:`|j\rangle`. + Here :math:`A_{Ij} =\cos \theta_j` and :math:`A_{Xj} =-i \sin \theta_j`. + This form is exactly identical to the unitary of :math:`R_X(\theta_j)` gate, + with :math:`\theta_0 =\Delta_{IX} + \Omega_{ZX}` and + :math:`\theta_1 =\Delta_{IX} - \Omega_{ZX}`, + given we calibrated the gate to have :math:`\Omega_{ZX} = \phi + \Delta_{ZX}` + so that :math:`\phi` corresponds to the experiment parameter ``angle``, + or the angle of the controlled rotation we want, + e.g. :math:`\phi = \pi/2` for the CNOT gate. + The total evolution during the echo sequence will be expressed by + :math:`R_X(\pi + \Delta_{ZX} \pm \Delta_{IX})` for the control qubit state + 0 and 1, respectively. + + In the echo circuit, the non-local ZX rotation of angle :math:`\phi` is undone by + the :math:`R_X(\mp \phi)` rotation whose sign depends on the control qubit state. + Therefore, the rotation error :math:`\Delta_{ZX}` from the target + angle :math:`\phi` is amplified. + Repeating this sequence N times forms a typical ping-pong oscillation pattern + in the measured target qubit population, + which may be fit by :math:`P(N) = \cos(N (d\theta_j + \pi) + \phi_{\rm offset})`, + where :math:`d\theta_j = \Delta_{ZX}\pm \Delta_{IX}`. + By combining error amplification fit parameters :math:`d\theta_j` for + different control qubit states :math:`j`, we can differentiate the local (IX) from the + non-local (ZX) dynamics. + + In this pulse sequence, the pi-pulse echo is applied to the target qubit + around the same axis as the interrogated error. + This cancels out the errors in other axes since the errors anti-commute with the echo, + e.g. :math:`XYX = -Y`, while the error in the interrogated axis is accumulated. + This is how the sequence selectively amplifies the error axis. + + However, strictly speaking, non-X error terms :math:`{\cal P}` do not commute + with the primary :math:`ZX` term of the Hamiltonian, and + they are skewed by the significant nonzero commutator :math:`[ZX, {\cal P}]`. + Thus this sequence pattern might underestimate the coefficients in non-X axes. + Usually this is less impactful if the errors of interest are sufficiently small, + but you should keep this in mind. + + # section: example + This experiment requires you to provide the pulse definition of the ``heat`` gate. + This gate should implement the ZX Hamiltonian with rotation angle :math:`\phi`. + This might be done in the following workflow. + + .. code-block:: python + + from qiskit import pulse + from qiskit.test.mock import FakeJakarta + from qiskit_experiments.library import ZXHeat + + backend = FakeJakarta() + qubits = 0, 1 + + # Write pulse schedule implementing ZX Hamiltonian + heat_pulse = pulse.GaussianSquare(100, 1, 10, 5) + + with pulse.build(backend) as heat_sched: + pulse.play(heat_pulse, pulse.control_channels(*qubits)[0]) + + # Map schedule to the gate + my_inst_map = backend.defaults().instruction_schedule_map + my_inst_map.add("heat", qubits, heat_sched) + + # Set up experiment + heat_exp = ZXHeat(qubits, error_axis="x", backend=backend) + heat_exp.set_transpile_options(inst_map=my_inst_map) + heat_exp.run() + + # section: note + The ``heat`` gate represents the entangling pulse sequence. + This gate is usually not provided by the backend, and users must thus provide + the pulse schedule to run this experiment. + This pulse sequence should be pre-calibrated to roughly implement the + ZX(angle) evolution otherwise selective amplification doesn't work properly. + + # section: see_also + qiskit_experiments.library.hamiltonian.HeatElement + + # section: analysis_ref + :py:class:`HeatAnalysis` + + # section: reference + .. ref_arxiv:: 1 2007.02925 + """ + + def __init__( + self, + qubits: Tuple[int, int], + error_axis: str, + backend: Optional[Backend] = None, + angle: Optional[float] = np.pi / 2, + ): + """Create new HEAT experiment for the entangler of ZX generator. + + Args: + qubits: Index of control and target qubit, respectively. + error_axis: String representation of axis that amplifies the error, + either one of "x", "y", "z". + backend: Optional, the backend to run the experiment on. + angle: Angle of controlled rotation, which defaults to pi/2. + + Raises: + ValueError: When ``error_axis`` is not one of "x", "y", "z". + """ + + amplification_exps = [] + for control in (0, 1): + prep = QuantumCircuit(2) + echo = QuantumCircuit(2) + meas = QuantumCircuit(2) + + if control: + prep.x(0) + echo.rx(angle, 1) + else: + echo.rx(-angle, 1) + + if error_axis == "x": + prep.rx(np.pi / 2, 1) + echo.rx(np.pi, 1) + elif error_axis == "y": + prep.ry(np.pi / 2, 1) + echo.ry(np.pi, 1) + elif error_axis == "z": + prep.ry(np.pi / 2, 1) + echo.rz(np.pi, 1) + meas.rx(-np.pi / 2, 1) + else: + raise ValueError(f"Invalid error term {error_axis}.") + + exp = HeatElement( + qubits=qubits, + prep_circ=prep, + echo_circ=echo, + meas_circ=meas, + backend=backend, + ) + amplification_exps.append(exp) + + heat_analysis = HeatAnalysis( + fit_params=(f"d_heat_{error_axis}0", f"d_heat_{error_axis}1"), + out_params=(f"A_I{error_axis.upper()}", f"A_Z{error_axis.upper()}"), + ) + + super().__init__(experiments=amplification_exps, backend=backend) + + # override analysis. + self.analysis = heat_analysis + + @classmethod + def _default_experiment_options(cls) -> Options: + """Default experiment options. + + Experiment Options: + repetitions (Sequence[int]): A list of the number of echo repetitions. + heat_gate (Gate): A gate instance representing the entangling sequence. + """ + options = super()._default_experiment_options() + options.repetitions = list(range(21)) + options.heat_gate = Gate("heat", num_qubits=2, params=[]) + + return options + + @classmethod + def _default_transpile_options(cls) -> Options: + """Default transpile options.""" + options = super()._default_transpile_options() + options.basis_gates = ["sx", "x", "rz", "heat"] + options.optimization_level = 1 + + return options + + +class ZX90HeatXError(ZXHeat): + """HEAT experiment for X error amplification for ZX(pi/2) Hamiltonian. + + # section: see_also + qiskit_experiments.library.hamiltonian.ZXHeat + """ + + def __init__(self, qubits: Tuple[int, int], backend: Optional[Backend] = None): + """Create new experiment. + + qubits: Index of control and target qubit, respectively. + backend: Optional, the backend to run the experiment on. + """ + super().__init__( + qubits=qubits, + error_axis="x", + backend=backend, + angle=np.pi / 2, + ) + + +class ZX90HeatYError(ZXHeat): + """HEAT experiment for Y error amplification for ZX(pi/2) Hamiltonian. + + # section: see_also + qiskit_experiments.library.hamiltonian.ZXHeat + """ + + def __init__(self, qubits: Tuple[int, int], backend: Optional[Backend] = None): + """Create new experiment. + + qubits: Index of control and target qubit, respectively. + backend: Optional, the backend to run the experiment on. + """ + super().__init__( + qubits=qubits, + error_axis="y", + backend=backend, + angle=np.pi / 2, + ) + + +class ZX90HeatZError(ZXHeat): + """HEAT experiment for Z error amplification for ZX(pi/2) Hamiltonian. + + # section: see_also + qiskit_experiments.library.hamiltonian.ZXHeat + """ + + def __init__(self, qubits: Tuple[int, int], backend: Optional[Backend] = None): + """Create new experiment. + + qubits: Index of control and target qubit, respectively. + backend: Optional, the backend to run the experiment on. + """ + super().__init__( + qubits=qubits, + error_axis="z", + backend=backend, + angle=np.pi / 2, + ) diff --git a/releasenotes/notes/add-heat-experiment-047a73818407e733.yaml b/releasenotes/notes/add-heat-experiment-047a73818407e733.yaml new file mode 100644 index 0000000000..9b369bff3b --- /dev/null +++ b/releasenotes/notes/add-heat-experiment-047a73818407e733.yaml @@ -0,0 +1,24 @@ +--- +features: + - | + New experiment module :mod:`qiskit_experiments.library.hamiltonian` + has been added. This module collects experiments to characterize low level + system properties, such as device Hamiltonian. + - | + The `Hamiltonian Error Amplifying Tomography` (HEAT) experiment has been + added. This experiment consists of the base class + :class:`~qiskit_experiments.library.hamiltonian.BatchHeatHelper` + and :class:`~qiskit_experiments.library.hamiltonian.HeatElement` + class for experiment developers to implement their own HEAT experiment + for arbitrary generator and for specific interrogated error axis. + This can be realized by designing proper initialization circuit, + echo sequence, and measurement axis against the interrogated error axis. + + The HEAT experiment specific to the ZX-type generator + :class:`~qiskit_experiments.library.hamiltonian.ZXHeat` is also added + along with pre-configured experiments for + X (:class:`~qiskit_experiments.library.hamiltonian.ZX90HeatXError`), + Y (:class:`~qiskit_experiments.library.hamiltonian.ZX90HeatYError`), + Z (:class:`~qiskit_experiments.library.hamiltonian.ZX90HeatZError`) + error axis. These experiments are typically used to characterize + the Cross Resonance gate Hamiltonian implementing the CNOT gate. diff --git a/test/base.py b/test/base.py index 71480dbec1..fb724250cd 100644 --- a/test/base.py +++ b/test/base.py @@ -1,6 +1,6 @@ # This code is part of Qiskit. # -# (C) Copyright IBM 2021. +# (C) Copyright IBM 2021, 2022. # # This code is licensed under the Apache License, Version 2.0. You may # obtain a copy of this license in the LICENSE.txt file in the root directory diff --git a/test/hamiltonian/__init__.py b/test/hamiltonian/__init__.py new file mode 100644 index 0000000000..43fbcfb7e2 --- /dev/null +++ b/test/hamiltonian/__init__.py @@ -0,0 +1,13 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2022. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +"""Test cases for Hamiltonian characterization.""" diff --git a/test/hamiltonian/test_heat.py b/test/hamiltonian/test_heat.py new file mode 100644 index 0000000000..0c6ab7e44f --- /dev/null +++ b/test/hamiltonian/test_heat.py @@ -0,0 +1,327 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2022. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +""" +Test for the HEAT experiment +""" +from test.base import QiskitExperimentsTestCase + +import scipy.linalg as la +import numpy as np + +from ddt import ddt, data, unpack + +from qiskit import circuit, quantum_info as qi +from qiskit.providers.aer import AerSimulator +from qiskit_experiments.library.hamiltonian import HeatElement, HeatAnalysis +from qiskit_experiments.library import ZX90HeatXError, ZX90HeatYError, ZX90HeatZError +from qiskit_experiments.framework import BatchExperiment + + +class HeatExperimentsTestCase: + """Base class for HEAT experiment test.""" + + backend = AerSimulator() + + @staticmethod + def create_heat_gate(generator): + """Helper function to create HEAT gate for Aer simulator.""" + unitary = la.expm(-1j * generator) + + gate_decomp = circuit.QuantumCircuit(2) + gate_decomp.unitary(unitary, [0, 1]) + + heat_gate = circuit.Gate(f"heat_{hash(unitary.tobytes())}", 2, []) + heat_gate.add_decomposition(gate_decomp) + + return heat_gate + + +class TestHeatBase(QiskitExperimentsTestCase): + """Test for base classes.""" + + @staticmethod + def _create_fake_amplifier(prep_seed, echo_seed, meas_seed): + """Helper method to generate fake experiment.""" + prep = circuit.QuantumCircuit(2) + prep.compose(qi.random_unitary(4, seed=prep_seed).to_instruction(), inplace=True) + + echo = circuit.QuantumCircuit(2) + echo.compose(qi.random_unitary(4, seed=echo_seed).to_instruction(), inplace=True) + + meas = circuit.QuantumCircuit(2) + meas.compose(qi.random_unitary(4, seed=meas_seed).to_instruction(), inplace=True) + + exp = HeatElement( + qubits=(0, 1), + prep_circ=prep, + echo_circ=echo, + meas_circ=meas, + ) + + return exp + + def test_element_experiment_config(self): + """Test converting to and from config works""" + exp = self._create_fake_amplifier(123, 456, 789) + + loaded_exp = HeatElement.from_config(exp.config()) + self.assertNotEqual(exp, loaded_exp) + self.assertTrue(self.json_equiv(exp, loaded_exp)) + + def test_element_roundtrip_serializable(self): + """Test round trip JSON serialization""" + exp = self._create_fake_amplifier(123, 456, 789) + + self.assertRoundTripSerializable(exp, self.json_equiv) + + def test_analysis_config(self): + """Test converting analysis to and from config works""" + analysis = HeatAnalysis(fit_params=("i1", "i2"), out_params=("o1", "o2")) + loaded = HeatAnalysis.from_config(analysis.config()) + self.assertNotEqual(analysis, loaded) + self.assertEqual(analysis.config(), loaded.config()) + + def test_create_circuit(self): + """Test HEAT circuit generation.""" + prep = circuit.QuantumCircuit(2) + prep.x(0) + prep.ry(np.pi / 2, 1) + + echo = circuit.QuantumCircuit(2) + echo.z(1) + + meas = circuit.QuantumCircuit(2) + meas.rx(np.pi / 2, 1) + + exp = HeatElement( + qubits=(0, 1), + prep_circ=prep, + echo_circ=echo, + meas_circ=meas, + ) + exp.set_experiment_options(repetitions=[2]) + + heat_circ = exp.circuits()[0] + + ref_circ = circuit.QuantumCircuit(2, 1) + ref_circ.x(0) + ref_circ.ry(np.pi / 2, 1) + ref_circ.barrier() + ref_circ.append(exp.experiment_options.heat_gate, [0, 1]) + ref_circ.z(1) + ref_circ.barrier() + ref_circ.append(exp.experiment_options.heat_gate, [0, 1]) + ref_circ.z(1) + ref_circ.barrier() + ref_circ.rx(np.pi / 2, 1) + ref_circ.measure(1, 0) + + self.assertEqual(heat_circ, ref_circ) + + +@ddt +class TestZXHeat(QiskitExperimentsTestCase, HeatExperimentsTestCase): + """Test ZX Heat experiment.""" + + @staticmethod + def create_generator( + angle=np.pi / 2, + e_zx=0.0, + e_zy=0.0, + e_zz=0.0, + e_ix=0.0, + e_iy=0.0, + e_iz=0.0, + ): + """Create generator Hamiltonian represented by numpy array.""" + generator_ham = ( + 0.5 + * ( + (angle + e_zx) * qi.Operator.from_label("XZ") + + e_zy * qi.Operator.from_label("YZ") + + e_zz * qi.Operator.from_label("ZZ") + + e_ix * qi.Operator.from_label("XI") + + e_iy * qi.Operator.from_label("YI") + + e_iz * qi.Operator.from_label("ZI") + ).data + ) + + return generator_ham + + def test_transpile_options_sync(self): + """Test if transpile option set to composite can update all component experiments.""" + exp = ZX90HeatXError(qubits=(0, 1), backend=self.backend) + basis_exp0 = exp.component_experiment(0).transpile_options.basis_gates + basis_exp1 = exp.component_experiment(1).transpile_options.basis_gates + self.assertListEqual(basis_exp0, ["sx", "x", "rz", "heat"]) + self.assertListEqual(basis_exp1, ["sx", "x", "rz", "heat"]) + + # override from composite + exp.set_transpile_options(basis_gates=["sx", "x", "rz", "my_heat"]) + new_basis_exp0 = exp.component_experiment(0).transpile_options.basis_gates + new_basis_exp1 = exp.component_experiment(1).transpile_options.basis_gates + self.assertListEqual(new_basis_exp0, ["sx", "x", "rz", "my_heat"]) + self.assertListEqual(new_basis_exp1, ["sx", "x", "rz", "my_heat"]) + + def test_experiment_options_sync(self): + """Test if experiment option set to composite can update all component experiments.""" + exp = ZX90HeatXError(qubits=(0, 1), backend=self.backend) + reps_exp0 = exp.component_experiment(0).experiment_options.repetitions + reps_exp1 = exp.component_experiment(1).experiment_options.repetitions + self.assertListEqual(reps_exp0, list(range(21))) + self.assertListEqual(reps_exp1, list(range(21))) + + # override from composite + exp.set_experiment_options(repetitions=[1, 2, 3]) + new_reps_exp0 = exp.component_experiment(0).experiment_options.repetitions + new_reps_exp1 = exp.component_experiment(1).experiment_options.repetitions + self.assertListEqual(new_reps_exp0, [1, 2, 3]) + self.assertListEqual(new_reps_exp1, [1, 2, 3]) + + @data( + [0.08, -0.01], + [-0.05, 0.13], + [0.15, 0.02], + [-0.04, -0.02], + [0.0, 0.12], + [0.12, 0.0], + ) + @unpack + def test_x_error_amplification(self, e_zx, e_ix): + """Test for X error amplification.""" + exp = ZX90HeatXError(qubits=(0, 1), backend=self.backend) + generator = self.create_generator(e_zx=e_zx, e_ix=e_ix) + gate = self.create_heat_gate(generator) + exp.set_experiment_options(heat_gate=gate) + exp.set_transpile_options(basis_gates=["x", "sx", "rz", "unitary"]) + + exp_data = exp.run() + self.assertExperimentDone(exp_data) + + self.assertAlmostEqual( + exp_data.analysis_results("A_IX").value.nominal_value, e_ix, delta=0.01 + ) + self.assertAlmostEqual( + exp_data.analysis_results("A_ZX").value.nominal_value, e_zx, delta=0.01 + ) + + @data( + [0.02, -0.01], + [-0.05, 0.03], + [0.03, 0.02], + [-0.04, -0.01], + [0.0, 0.01], + [0.01, 0.0], + ) + @unpack + def test_y_error_amplification(self, e_zy, e_iy): + """Test for Y error amplification.""" + exp = ZX90HeatYError(qubits=(0, 1), backend=self.backend) + generator = self.create_generator(e_zy=e_zy, e_iy=e_iy) + gate = self.create_heat_gate(generator) + exp.set_experiment_options(heat_gate=gate) + exp.set_transpile_options(basis_gates=["x", "sx", "rz", "unitary"]) + + exp_data = exp.run() + self.assertExperimentDone(exp_data) + + # The factor 0.7 is estimated from numerical analysis, which comes from ZX commutator term. + # Note that this number may depend on magnitude of coefficients. + self.assertAlmostEqual( + exp_data.analysis_results("A_IY").value.nominal_value, 0.7 * e_iy, delta=0.01 + ) + self.assertAlmostEqual( + exp_data.analysis_results("A_ZY").value.nominal_value, 0.7 * e_zy, delta=0.01 + ) + + @data( + [0.02, -0.01], + [-0.05, 0.03], + [0.03, 0.02], + [-0.04, -0.01], + [0.0, 0.01], + [0.01, 0.0], + ) + @unpack + def test_z_error_amplification(self, e_zz, e_iz): + """Test for Z error amplification.""" + exp = ZX90HeatZError(qubits=(0, 1), backend=self.backend) + generator = self.create_generator(e_zz=e_zz, e_iz=e_iz) + gate = self.create_heat_gate(generator) + exp.set_experiment_options(heat_gate=gate) + exp.set_transpile_options(basis_gates=["x", "sx", "rz", "unitary"]) + + exp_data = exp.run() + self.assertExperimentDone(exp_data) + + # The factor 0.7 is estimated from numerical analysis, which comes from ZX commutator term. + # Note that this number may depend on magnitude of coefficients. + self.assertAlmostEqual( + exp_data.analysis_results("A_IZ").value.nominal_value, 0.7 * e_iz, delta=0.01 + ) + self.assertAlmostEqual( + exp_data.analysis_results("A_ZZ").value.nominal_value, 0.7 * e_zz, delta=0.01 + ) + + @data(123, 456) + def test_pseudo_calibration(self, seed): + """Test calibration with HEAT. + + This is somewhat of an integration test that covers multiple aspects of the experiment. + + The protocol of this test is as follows: + + First, this generates random Hamiltonian with multiple finite error terms. + Then errors in every axis is measured by three HEAT experiments as + a batch experiment, then inferred error values are subtracted from the + actual errors randomly determined. Repeating this eventually converges into + zero-ish errors in all axes if HEAT experiments work correctly. + + This checks if experiment sequence is designed correctly, and also checks + if HEAT experiment can be batched. + Note that HEAT itself is a batch experiment of amplifications. + """ + np.random.seed(seed) + coeffs = np.random.normal(0, 0.03, 6) + terms = ["e_zx", "e_zy", "e_zz", "e_ix", "e_iy", "e_iz"] + + errors_dict = dict(zip(terms, coeffs)) + + for _ in range(10): + generator = self.create_generator(**errors_dict) + gate = self.create_heat_gate(generator) + + exp_x = ZX90HeatXError(qubits=(0, 1)) + exp_x.set_experiment_options(heat_gate=gate) + exp_x.set_transpile_options(basis_gates=["x", "sx", "rz", "unitary"]) + + exp_y = ZX90HeatYError(qubits=(0, 1)) + exp_y.set_experiment_options(heat_gate=gate) + exp_y.set_transpile_options(basis_gates=["x", "sx", "rz", "unitary"]) + + exp_z = ZX90HeatZError(qubits=(0, 1)) + exp_z.set_experiment_options(heat_gate=gate) + exp_z.set_transpile_options(basis_gates=["x", "sx", "rz", "unitary"]) + + exp = BatchExperiment([exp_x, exp_y, exp_z], backend=self.backend) + exp_data = exp.run() + self.assertExperimentDone(exp_data) + + for n, tp in enumerate(["x", "y", "z"]): + a_zp = exp_data.child_data(n).analysis_results(f"A_Z{tp.upper()}") + a_ip = exp_data.child_data(n).analysis_results(f"A_I{tp.upper()}") + errors_dict[f"e_z{tp}"] -= a_zp.value.nominal_value + errors_dict[f"e_i{tp}"] -= a_ip.value.nominal_value + + for v in errors_dict.values(): + self.assertAlmostEqual(v, 0.0, delta=0.005)