diff --git a/qiskit_experiments/framework/composite/composite_analysis.py b/qiskit_experiments/framework/composite/composite_analysis.py index fab57af662..0d377de9ab 100644 --- a/qiskit_experiments/framework/composite/composite_analysis.py +++ b/qiskit_experiments/framework/composite/composite_analysis.py @@ -13,11 +13,11 @@ Composite Experiment Analysis class. """ -from typing import List, Dict - +from typing import List, Dict, Union import numpy as np from qiskit.result import marginal_counts from qiskit_experiments.framework import BaseAnalysis, ExperimentData +from qiskit_experiments.exceptions import AnalysisError class CompositeAnalysis(BaseAnalysis): @@ -45,23 +45,60 @@ class CompositeAnalysis(BaseAnalysis): reconstructed from the parent composite experiment data. """ + def __init__(self, analyses: List[BaseAnalysis]): + """Initialize a composite analysis class. + + Args: + analyses: a list of component experiment analysis objects. + """ + super().__init__() + self._analyses = analyses + + def component_analysis(self, index=None) -> Union[BaseAnalysis, List[BaseAnalysis]]: + """Return the component experiment Analysis object""" + if index is None: + return self._analyses + return self._analyses[index] + def _run_analysis(self, experiment_data: ExperimentData): + # Return list of experiment data containers for each component experiment + # containing the marginalied data from the composite experiment + component_exp_data = self._component_experiment_data(experiment_data) + + # Run the component analysis on each component data + for sub_exp_data, sub_analysis in zip(component_exp_data, self._analyses): + # Since copy for replace result is handled at the parent level + # we always run with replace result on component analysis + sub_analysis.run(sub_exp_data, replace_results=True) + + # Wait for all component analysis to finish before returning + # the parent experiment analysis results + for sub_exp_data in component_exp_data: + sub_exp_data.block_for_results() + + return [], [] + + def _component_experiment_data(self, experiment_data: ExperimentData) -> List[ExperimentData]: + """Return a list of component child experiment data""" + # Initialize component data for updating and get the experiment IDs for + # the component child experiments in case there are other child experiments + # in the experiment data + component_ids = self._initialize_components(experiment_data) + if len(component_ids) != len(self._analyses): + raise AnalysisError( + "Number of experiment components does not match number of" + " component analysis classes" + ) + # Extract job metadata for the component experiments so it can be added # to the child experiment data incase it is required by the child experiments # analysis classes - composite_exp = experiment_data.experiment - component_exps = composite_exp.component_experiment() component_metadata = experiment_data.metadata.get( - "component_metadata", [{}] * composite_exp.num_experiments + "component_metadata", [{}] * len(component_ids) ) - # Initialize component data for updating and get the experiment IDs for - # the component child experiments in case there are other child experiments - # in the experiment data - component_ids = self._initialize_components(composite_exp, experiment_data) - # Compute marginalize data for each component experiment - marginalized_data = self._marginalize_data(experiment_data.data()) + marginalized_data = self._component_data(experiment_data.data()) # Add the marginalized component data and component job metadata # to each component child experiment. Note that this will clear @@ -69,7 +106,8 @@ def _run_analysis(self, experiment_data: ExperimentData): # child data is handled by the `replace_results` kwarg of the # parent container it is safe to always clear and replace the # results of child containers in this step - for i, (sub_data, sub_exp) in enumerate(zip(marginalized_data, component_exps)): + component_data = [] + for i, sub_data in enumerate(marginalized_data): sub_exp_data = experiment_data.child_data(component_ids[i]) # Clear any previously stored data and add marginalized data @@ -78,44 +116,11 @@ def _run_analysis(self, experiment_data: ExperimentData): # Add component job metadata sub_exp_data.metadata.update(component_metadata[i]) + component_data.append(sub_exp_data) - # Run analysis - # Since copy for replace result is handled at the parent level - # we always run with replace result on component analysis - sub_exp.analysis.run(sub_exp_data, replace_results=True) - - # Wait for all component analysis to finish before returning - # the parent experiment analysis results - for comp_id in component_ids: - experiment_data.child_data(comp_id).block_for_results() - - return [], [] - - def _initialize_components(self, experiment, experiment_data): - """Initialize child data components and return list of child experiment IDs""" - # Check if component child experiment data containers have already - # been created. If so the list of indices for their positions in the - # ordered dict should exist. Index is used to extract the experiment - # IDs for each child experiment which can change when re-running analysis - # if replace_results=False, so that we update the correct child data - # for each component experiment - component_index = experiment_data.metadata.get("component_child_index", []) - if not component_index: - # If the experiment Construct component data and update indices - start_index = len(experiment_data.child_data()) - component_index = [] - for i, sub_exp in enumerate(experiment.component_experiment()): - sub_data = sub_exp._initialize_experiment_data() - experiment_data.add_child_data(sub_data) - component_index.append(start_index + i) - experiment_data.metadata["component_child_index"] = component_index - - # Child components exist so we can get their ID for accessing them - child_ids = experiment_data._child_data.keys() - component_ids = [child_ids[idx] for idx in component_index] - return component_ids + return component_data - def _marginalize_data(self, composite_data: List[Dict]) -> List[Dict]: + def _component_data(self, composite_data: List[Dict]) -> List[List[Dict]]: """Return marginalized data for component experiments""" # Marginalize data marginalized_data = {} @@ -148,3 +153,33 @@ def _marginalize_data(self, composite_data: List[Dict]) -> List[Dict]: # Sort by index return [marginalized_data[i] for i in sorted(marginalized_data.keys())] + + def _initialize_components(self, experiment_data: ExperimentData) -> List[str]: + """Initialize child data components and return list of child experiment IDs""" + # Check if component child experiment data containers have already + # been created. If so the list of indices for their positions in the + # ordered dict should exist. Index is used to extract the experiment + # IDs for each child experiment which can change when re-running analysis + # if replace_results=False, so that we update the correct child data + # for each component experiment + component_index = experiment_data.metadata.get("component_child_index", []) + if not component_index: + experiment = experiment_data.experiment + if experiment is None: + raise AnalysisError( + "Cannot run composite analysis on an experiment data without either " + "a composite experiment, or composite experiment metadata." + ) + # If the experiment Construct component data and update indices + start_index = len(experiment_data.child_data()) + component_index = [] + for i, sub_exp in enumerate(experiment.component_experiment()): + sub_data = sub_exp._initialize_experiment_data() + experiment_data.add_child_data(sub_data) + component_index.append(start_index + i) + experiment_data.metadata["component_child_index"] = component_index + + # Child components exist so we can get their ID for accessing them + child_ids = experiment_data._child_data.keys() + component_ids = [child_ids[idx] for idx in component_index] + return component_ids diff --git a/qiskit_experiments/framework/composite/composite_experiment.py b/qiskit_experiments/framework/composite/composite_experiment.py index 34325eda4d..955ef9bedd 100644 --- a/qiskit_experiments/framework/composite/composite_experiment.py +++ b/qiskit_experiments/framework/composite/composite_experiment.py @@ -13,11 +13,12 @@ Composite Experiment abstract base class. """ -from typing import List, Sequence, Optional +from typing import List, Sequence, Optional, Union from abc import abstractmethod import warnings from qiskit.providers.backend import Backend from qiskit_experiments.framework import BaseExperiment, ExperimentData +from qiskit_experiments.framework.base_analysis import BaseAnalysis from .composite_analysis import CompositeAnalysis @@ -41,9 +42,10 @@ def __init__( """ self._experiments = experiments self._num_experiments = len(experiments) + analysis = CompositeAnalysis([exp.analysis for exp in self._experiments]) super().__init__( qubits, - analysis=CompositeAnalysis(), + analysis=analysis, backend=backend, experiment_type=experiment_type, ) @@ -57,8 +59,9 @@ def num_experiments(self): """Return the number of sub experiments""" return self._num_experiments - def component_experiment(self, index=None): + def component_experiment(self, index=None) -> Union[BaseExperiment, List[BaseExperiment]]: """Return the component Experiment object. + Args: index (int): Experiment index, or ``None`` if all experiments are to be returned. Returns: @@ -68,9 +71,16 @@ def component_experiment(self, index=None): return self._experiments return self._experiments[index] - def component_analysis(self, index): + def component_analysis(self, index=None) -> Union[BaseAnalysis, List[BaseAnalysis]]: """Return the component experiment Analysis object""" - return self.component_experiment(index).analysis() + warnings.warn( + "The `component_analysis` method is deprecated as of " + "qiskit-experiments 0.3.0 and will be removed in the 0.4.0 release." + " Use `analysis.component_analysis` instead.", + DeprecationWarning, + stacklevel=2, + ) + return self.analysis.component_analysis(index) def copy(self) -> "BaseExperiment": """Return a copy of the experiment""" diff --git a/releasenotes/notes/composite-analysis-c3119d5d2e64ce78.yaml b/releasenotes/notes/composite-analysis-c3119d5d2e64ce78.yaml new file mode 100644 index 0000000000..5af6213086 --- /dev/null +++ b/releasenotes/notes/composite-analysis-c3119d5d2e64ce78.yaml @@ -0,0 +1,18 @@ +--- +features: + - | + Adds :meth:`.CompositeAnalysis.component_analysis` method for accessing + a component analysis class object from a composite analysis object. +upgrade: + - | + :class:`.CompositeAnalysis` initialization is changed to require a list of + :class:`.BaseAnalysis` objects so that these are stored in the class, rather + than being accessed later via a composite experiment. This initialization is + handled automatically by :class:`.ParallelExperiment` and + :class:`.BatchExperiment` composite experiments. +deprecations: + - | + The :meth:`.CompositeExperiment.component_analysis` method has been + deprecated. Component analysis classes should now be directly accessed + from a :meth:`.CompositeAnalysis` object using the + :meth:.`CompositeAnalysis.component_analysis` method. diff --git a/test/test_composite.py b/test/test_composite.py index 8532007659..f5f05079ae 100644 --- a/test/test_composite.py +++ b/test/test_composite.py @@ -16,7 +16,7 @@ import uuid from test.fake_backend import FakeBackend -from test.fake_experiment import FakeExperiment +from test.fake_experiment import FakeExperiment, FakeAnalysis from test.fake_service import FakeService from test.base import QiskitExperimentsTestCase @@ -467,3 +467,32 @@ def circuits(self): self.assertEqual(len(childdata.data()), len(child_counts)) for circ_data, circ_counts in zip(childdata.data(), child_counts): self.assertDictEqual(circ_data["counts"], circ_counts) + + def test_composite_analysis_options(self): + """Test setting component analysis options""" + + class Analysis(FakeAnalysis): + """Fake analysis class with options""" + + @classmethod + def _default_options(cls): + opts = super()._default_options() + opts.option1 = None + opts.option2 = None + return opts + + exp1 = FakeExperiment([0]) + exp1.analysis = Analysis() + exp2 = FakeExperiment([1]) + exp2.analysis = Analysis() + par_exp = ParallelExperiment([exp1, exp2]) + + # Set new analysis classes to component exp objects + opt1_val = 9000 + opt2_val = 2113 + exp1.analysis.set_options(option1=opt1_val) + exp2.analysis.set_options(option2=opt2_val) + + # Check this is reflected in parallel experiment + self.assertEqual(par_exp.analysis.component_analysis(0).options.option1, opt1_val) + self.assertEqual(par_exp.analysis.component_analysis(1).options.option2, opt2_val) diff --git a/test/test_tomography.py b/test/test_tomography.py index 11d2d05ebb..8cd8b80ce2 100644 --- a/test/test_tomography.py +++ b/test/test_tomography.py @@ -496,6 +496,50 @@ def test_parallel_exp(self): target_fid = qi.process_fidelity(state, targets[i], require_tp=False, require_cp=False) self.assertAlmostEqual(fid, target_fid, places=6, msg="result fidelity is incorrect") + def test_mixed_batch_exp(self): + """Test batch state and process tomography experiment""" + # Subsystem unitaries + state_op = qi.random_unitary(2, seed=321) + chan_op = qi.random_unitary(2, seed=123) + + state_target = qi.Statevector(state_op.to_instruction()) + chan_target = qi.Choi(chan_op.to_instruction()) + + state_exp = StateTomography(state_op) + chan_exp = ProcessTomography(chan_op) + batch_exp = BatchExperiment([state_exp, chan_exp]) + + # Run batch experiments + backend = AerSimulator(seed_simulator=9000) + par_data = batch_exp.run(backend) + self.assertExperimentDone(par_data) + + f_threshold = 0.95 + + # Check state tomo results + state_results = par_data.child_data(0).analysis_results() + state = filter_results(state_results, "state").value + + # Check fit state fidelity + state_fid = filter_results(state_results, "state_fidelity").value + self.assertGreater(state_fid, f_threshold, msg="fit fidelity is low") + + # Manually check fidelity + target_fid = qi.state_fidelity(state, state_target, validate=False) + self.assertAlmostEqual(state_fid, target_fid, places=6, msg="result fidelity is incorrect") + + # Check process tomo results + chan_results = par_data.child_data(1).analysis_results() + chan = filter_results(chan_results, "state").value + + # Check fit process fidelity + chan_fid = filter_results(chan_results, "process_fidelity").value + self.assertGreater(chan_fid, f_threshold, msg="fit fidelity is low") + + # Manually check fidelity + target_fid = qi.process_fidelity(chan, chan_target, require_cp=False, require_tp=False) + self.assertAlmostEqual(chan_fid, target_fid, places=6, msg="result fidelity is incorrect") + def test_experiment_config(self): """Test converting to and from config works""" exp = ProcessTomography(teleport_circuit(), measurement_qubits=[2], preparation_qubits=[0])