Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WIP] Experiment serialization and reconstruction #1423

Open
wants to merge 15 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions qiskit_experiments/curve_analysis/base_curve_analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
Base class of curve analysis.
"""

from collections import OrderedDict
import warnings
from abc import ABC, abstractmethod
from typing import Dict, List, Union
Expand All @@ -29,6 +30,7 @@
BaseAnalysis,
ExperimentData,
Options,
AnalysisConfig,
)
from qiskit_experiments.visualization import (
BaseDrawer,
Expand Down Expand Up @@ -422,3 +424,38 @@ def _initialize(
DeprecationWarning,
)
self.set_options(data_subfit_map=data_subfit_map)

def config(self) -> AnalysisConfig:
"""Return the config dataclass for this analysis. We replicate the method from `BaseAnalysis`
because we cannot directly modify the returned object. This limitation arises from our use of
it hashable we use `dataclasses.dataclass(frozen=True)` to ensure its hashability.
Additionally, we include the `plotter` and `drawer` options, which are specific to
this context."""
args = tuple(getattr(self, "__init_args__", OrderedDict()).values())
kwargs = dict(getattr(self, "__init_kwargs__", OrderedDict()))
# Only store non-default valued options
options = dict((key, getattr(self._options, key)) for key in self._set_options)
if isinstance(self.plotter, BasePlotter):
plotter = self.plotter.config()
else:
plotter = None

return AnalysisConfig(
cls=type(self),
args=args,
kwargs=kwargs,
options=options,
plotter=plotter,
)

@classmethod
def from_config(cls, config: Union[AnalysisConfig, Dict]) -> "BaseCurveAnalysis":
"""Initialize a curve analysis class from analysis config"""
if isinstance(config, dict):
config = AnalysisConfig(**config)
ret = super().from_config(config)
if config.plotter:
ret.options.plotter = config.plotter.plotter()
else:
ret.options.plotter = None
return ret
90 changes: 88 additions & 2 deletions qiskit_experiments/framework/base_experiment.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,23 +12,30 @@
"""
Base Experiment class.
"""

import warnings
from abc import ABC, abstractmethod
import copy
from collections import OrderedDict
import logging
import traceback
from typing import Sequence, Optional, Tuple, List, Dict, Union

from qiskit import transpile, QuantumCircuit
from qiskit.providers import Job, Backend
from qiskit.providers import Provider, Job, Backend
from qiskit.exceptions import QiskitError
from qiskit.qobj.utils import MeasLevel
from qiskit.providers.options import Options
from qiskit_ibm_experiment import IBMExperimentService
from qiskit_experiments.framework import BackendData
from qiskit_experiments.framework.store_init_args import StoreInitArgs
from qiskit_experiments.framework.base_analysis import BaseAnalysis
from qiskit_experiments.framework.experiment_data import ExperimentData
from qiskit_experiments.framework.configs import ExperimentConfig
from qiskit_experiments.framework.json import ExperimentDecoder
from qiskit_experiments.database_service import Qubit
from qiskit_experiments.database_service.utils import zip_to_objs

LOG = logging.getLogger(__name__)


class BaseExperiment(ABC, StoreInitArgs):
Expand Down Expand Up @@ -171,13 +178,20 @@ def config(self) -> ExperimentConfig:
(key, getattr(self._transpile_options, key)) for key in self._set_transpile_options
)
run_options = dict((key, getattr(self._run_options, key)) for key in self._set_run_options)

if isinstance(self.analysis, BaseAnalysis):
analysis_config = self.analysis.config()
else:
analysis_config = None

return ExperimentConfig(
cls=type(self),
args=args,
kwargs=kwargs,
experiment_options=experiment_options,
transpile_options=transpile_options,
run_options=run_options,
analysis=analysis_config,
)

@classmethod
Expand All @@ -192,8 +206,79 @@ def from_config(cls, config: Union[ExperimentConfig, Dict]) -> "BaseExperiment":
ret.set_transpile_options(**config.transpile_options)
if config.run_options:
ret.set_run_options(**config.run_options)
if config.analysis:
ret.analysis = config.analysis.analysis()
else:
ret.analysis = None
return ret

@classmethod
def load(
cls,
experiment_id: str,
service: Optional[IBMExperimentService] = None,
provider: Optional[Provider] = None,
) -> "BaseExperiment":
"""Load a saved experiment from a database service.

Args:
experiment_id: Experiment ID.
service: the database service.
provider: an IBMProvider required for loading the experiment data and
can be used to initialize the service. When using
:external+qiskit_ibm_runtime:doc:`qiskit-ibm-runtime <index>`,
this is the :class:`~qiskit_ibm_runtime.QiskitRuntimeService` and should
not be confused with the experiment database service
:meth:`qiskit_ibm_experiment.IBMExperimentService`.

Returns:
The reconstructed experiment.
Raises:
QiskitError: If not service nor provider were given.
"""
if service is None:
if provider is None:
raise QiskitError(
"Loading an experiment requires a valid Qiskit provider or experiment service."
)
service = ExperimentData.get_service_from_provider(provider)

data = service.experiment(experiment_id, json_decoder=ExperimentDecoder)
# loading metadata artifact if exist
if service.experiment_has_file(experiment_id, ExperimentData._metadata_filename):
metadata = service.file_download(
experiment_id, ExperimentData._metadata_filename, json_decoder=ExperimentDecoder
)
data.metadata.update(metadata)

# Recreate artifacts
experiment_config_filename = "experiment_config"
try:
if experiment_config_filename in data.metadata:
if service.experiment_has_file(experiment_id, experiment_config_filename):
artifact_file = service.file_download(experiment_id, experiment_config_filename)

experiment_config_artifact = zip_to_objs(artifact_file, json_decoder=ExperimentDecoder)[0]
backend_name = data.backend
reconstructed_experiment = cls.from_config(experiment_config_artifact.data)
if backend_name:
reconstructed_experiment.backend = provider.get_backend(backend_name)
else:
warnings.warn("No backend for loaded data.")
return reconstructed_experiment
else:
raise QiskitError(
"The experiment doesn't have saved experiment in the DB to load."
)
else:
raise QiskitError(
f"No '{experiment_config_filename}' field in the experiment metadata. can't load "
f"the experiment."
)

except Exception: # pylint: disable=broad-except:
LOG.error("Unable to load artifacts: %s", traceback.format_exc())

def run(
self,
backend: Optional[Backend] = None,
Expand Down Expand Up @@ -475,6 +560,7 @@ def _metadata(self) -> Dict[str, any]:
By default, this assumes the experiment is running on qubits only. Subclasses can override
this method to add custom experiment metadata to the returned experiment result data.
"""

metadata = {
"physical_qubits": list(self.physical_qubits),
"device_components": list(map(Qubit, self.physical_qubits)),
Expand Down
136 changes: 116 additions & 20 deletions qiskit_experiments/framework/configs.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,46 +21,90 @@


@dataclasses.dataclass(frozen=True)
class ExperimentConfig:
"""Store configuration settings for an Experiment class.
class DrawerConfig:
"""Store configuration settings for a Drawer class.

This stores the current configuration of a :class:`.BaseExperiment` and can be used to
reconstruct the experiment using either the :meth:`experiment` property if the experiment class
type is currently stored, or the :meth:`~.BaseExperiment.from_config` class method of the
appropriate experiment.
This stores the current configuration of a :class:`.BaseDrawer` and can be used to reconstruct
the drawer class using either the :meth:`drawer` property if the drawer class type is
currently stored, or the :meth:`~.BaseDrawer.from_config` class method.
"""

cls: type = None
args: Tuple[Any] = dataclasses.field(default_factory=tuple)
kwargs: Dict[str, Any] = dataclasses.field(default_factory=dict)
experiment_options: Dict[str, Any] = dataclasses.field(default_factory=dict)
transpile_options: Dict[str, Any] = dataclasses.field(default_factory=dict)
run_options: Dict[str, Any] = dataclasses.field(default_factory=dict)
options: Dict[str, Any] = dataclasses.field(default_factory=dict)
figure_options: Dict[str, Any] = dataclasses.field(default_factory=dict)
version: str = __version__

def experiment(self):
"""Return the experiment constructed from this config.
def drawer(self):
"""Return the drawer class constructed from this config.

Returns:
BaseExperiment: The experiment reconstructed from the config.
BaseDrawer: The drawer reconstructed from the config.

Raises:
QiskitError: If the experiment class is not stored,
QiskitError: If the drawer class is not stored,
was not successful deserialized, or reconstruction
of the experiment fails.
of the drawer class fails.
"""
cls = self.cls
if cls is None:
raise QiskitError("No experiment class in experiment config")
raise QiskitError("No drawer class in drawer config")
if isinstance(cls, dict):
raise QiskitError(
"Unable to load experiment class. Try manually loading "
"experiment using `Experiment.from_config(config)` instead."
"Unable to load drawer class. Try manually loading "
"drawer using `Analysis.plotter.drawer.from_config(config)` instead."
)
try:
return cls.from_config(self)
except Exception as ex:
msg = "Unable to construct experiments from config."
msg = "Unable to construct drawer from config."
if cls.version != __version__:
msg += (
f" Note that config version ({cls.version}) differs from the current"
f" qiskit-experiments version ({__version__}). You could try"
" installing a compatible qiskit-experiments version."
)
raise QiskitError(f"{msg}\nError Message:\n{str(ex)}") from ex


@dataclasses.dataclass(frozen=True)
class PlotterConfig:
"""Store configuration settings for a Drawer class.

This stores the current configuration of a :class:`.BasePlotter` and can be used to reconstruct
the plotter class using either the :meth:`plotter` property if the plotter class type is
currently stored, or the :meth:`~.BasePlotter.from_config` class method.
"""

cls: type = None
options: Dict[str, Any] = dataclasses.field(default_factory=dict)
figure_options: Dict[str, Any] = dataclasses.field(default_factory=dict)
# drawer: Dict[str, Any] = dataclasses.field(default_factory=dict)
drawer: DrawerConfig = None
version: str = __version__

def plotter(self):
"""Return the plotter of the analysis class constructed from this config.

Returns:
BasePlotter: The plotter reconstructed from the config.

Raises:
QiskitError: If the plotter class is not stored,
was not successful deserialized, or reconstruction
of the plotter class fails.
"""
cls = self.cls
if cls is None:
raise QiskitError("No plotter class in plotter config")
if isinstance(cls, dict):
raise QiskitError(
"Unable to load plotter class. Try manually loading "
"analysis using `Analysis.plotter.from_config(config)` instead."
)
try:
return cls.from_config(self)
except Exception as ex:
msg = "Unable to construct plotter from config."
if cls.version != __version__:
msg += (
f" Note that config version ({cls.version}) differs from the current"
Expand All @@ -84,6 +128,7 @@ class AnalysisConfig:
args: Tuple[Any] = dataclasses.field(default_factory=tuple)
kwargs: Dict[str, Any] = dataclasses.field(default_factory=dict)
options: Dict[str, Any] = dataclasses.field(default_factory=dict)
plotter: PlotterConfig = None
version: str = __version__

def analysis(self):
Expand Down Expand Up @@ -116,3 +161,54 @@ def analysis(self):
" installing a compatible qiskit-experiments version."
)
raise QiskitError(f"{msg}\nError Message:\n{str(ex)}") from ex


@dataclasses.dataclass(frozen=True)
class ExperimentConfig:
"""Store configuration settings for an Experiment class.

This stores the current configuration of a :class:`.BaseExperiment` and can be used to
reconstruct the experiment using either the :meth:`experiment` property if the experiment class
type is currently stored, or the :meth:`~.BaseExperiment.from_config` class method of the
appropriate experiment.
"""

cls: type = None
args: Tuple[Any] = dataclasses.field(default_factory=tuple)
kwargs: Dict[str, Any] = dataclasses.field(default_factory=dict)
experiment_options: Dict[str, Any] = dataclasses.field(default_factory=dict)
transpile_options: Dict[str, Any] = dataclasses.field(default_factory=dict)
run_options: Dict[str, Any] = dataclasses.field(default_factory=dict)
analysis: AnalysisConfig = None
version: str = __version__

def experiment(self):
"""Return the experiment constructed from this config.

Returns:
BaseExperiment: The experiment reconstructed from the config.

Raises:
QiskitError: If the experiment class is not stored,
was not successful deserialized, or reconstruction
of the experiment fails.
"""
cls = self.cls
if cls is None:
raise QiskitError("No experiment class in experiment config")
if isinstance(cls, dict):
raise QiskitError(
"Unable to load experiment class. Try manually loading "
"experiment using `Experiment.from_config(config)` instead."
)
try:
return cls.from_config(self)
except Exception as ex:
msg = "Unable to construct experiments from config."
if cls.version != __version__:
msg += (
f" Note that config version ({cls.version}) differs from the current"
f" qiskit-experiments version ({__version__}). You could try"
" installing a compatible qiskit-experiments version."
)
raise QiskitError(f"{msg}\nError Message:\n{str(ex)}") from ex
Loading
Loading