diff --git a/qiskit_experiments/framework/composite/composite_analysis.py b/qiskit_experiments/framework/composite/composite_analysis.py index fb04ba50f9..bb441e06e4 100644 --- a/qiskit_experiments/framework/composite/composite_analysis.py +++ b/qiskit_experiments/framework/composite/composite_analysis.py @@ -13,15 +13,13 @@ Composite Experiment Analysis class. """ -from typing import List, Dict, Union, Optional, Tuple +from typing import List, Union, Optional, Tuple +import logging import warnings -import numpy as np -from qiskit.result import marginal_distribution -from qiskit.result.postprocess import format_counts_memory from qiskit_experiments.framework import BaseAnalysis, ExperimentData from qiskit_experiments.framework.analysis_result_data import AnalysisResultData -from qiskit_experiments.framework.base_analysis import _requires_copy -from qiskit_experiments.exceptions import AnalysisError + +LOG = logging.getLogger(__name__) class CompositeAnalysis(BaseAnalysis): @@ -120,242 +118,44 @@ def copy(self): ret._analyses = [analysis.copy() for analysis in ret._analyses] return ret - def run( - self, - experiment_data: ExperimentData, - replace_results: bool = False, - **options, - ) -> ExperimentData: - # Make a new copy of experiment data if not updating results - if not replace_results and _requires_copy(experiment_data): - experiment_data = experiment_data.copy() - - if not self._flatten_results: - # Initialize child components if they are not initialized - # This only needs to be done if results are not being flattened - self._add_child_data(experiment_data) - - # Run analysis with replace_results = True since we have already - # created the copy if it was required - return super().run(experiment_data, replace_results=True, **options) - def _run_analysis(self, experiment_data: ExperimentData): - # Return list of experiment data containers for each component experiment - # containing the marginalized data from the composite experiment - component_expdata = self._component_experiment_data(experiment_data) + child_data = experiment_data.child_data() + if len(child_data) == 0: + # Child data is automatically created when composite result data is added. + # Validate that child data size matches with number of analysis entries. + experiment_data.create_child_data()._init_children_data() + + if len(self._analyses) != len(child_data): + # Child data is automatically created when composite result data is added. + # Validate that child data size matches with number of analysis entries. + LOG.warning( + "Number of sub-analysis and child data don't match: \ + %d != %d. \ + Please check if the composite experiment and \ + analysis are properly instantiated.", + len(self._analyses), + len(child_data), + ) - # Run the component analysis on each component data - for i, sub_expdata in enumerate(component_expdata): + for sub_analysis, sub_data in zip(self._analyses, child_data): # Since copy for replace result is handled at the parent level # we always run with replace result on component analysis - self._analyses[i].run(sub_expdata, replace_results=True) - + sub_analysis.run(sub_data, replace_results=True) # Analysis is running in parallel so we add loop to wait # for all component analysis to finish before returning # the parent experiment analysis results - for sub_expdata in component_expdata: - sub_expdata.block_for_results() + for sub_data in child_data: + sub_data.block_for_results() # Optionally flatten results from all component experiments # for adding to the main experiment data container if self._flatten_results: - analysis_results, figures = self._combine_results(component_expdata) + analysis_results, figures = self._combine_results(child_data) for res in analysis_results: # Override experiment ID because entries are flattened res.experiment_id = experiment_data.experiment_id return analysis_results, figures return [], [] - def _component_experiment_data(self, experiment_data: ExperimentData) -> List[ExperimentData]: - """Return a list of marginalized experiment data for component experiments. - - Args: - experiment_data: a composite experiment data container. - - Returns: - The list of analysis-ready marginalized experiment data for each - component experiment. - - Raises: - AnalysisError: If the component experiment data cannot be extracted. - """ - if not self._flatten_results: - # Retrieve child data for component experiments for updating - component_index = experiment_data.metadata.get("component_child_index", []) - if not component_index: - raise AnalysisError("Unable to extract component child experiment data") - component_expdata = [experiment_data.child_data(i) for i in component_index] - else: - # Initialize temporary ExperimentData containers for - # each component experiment to analysis on. These will - # not be saved but results and figures will be collected - # from them - component_expdata = self._initialize_component_experiment_data(experiment_data) - - # Compute marginalize data for each component experiment - marginalized_data = self._marginalized_component_data(experiment_data.data()) - - # Add the marginalized component data and component job metadata - # to each component child experiment. Note that this will clear - # any currently stored data in the experiment. Since copying of - # 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 sub_expdata, sub_data in zip(component_expdata, marginalized_data): - # Clear any previously stored data and add marginalized data - sub_expdata._result_data.clear() - sub_expdata.add_data(sub_data) - - return component_expdata - - def _marginalized_component_data(self, composite_data: List[Dict]) -> List[List[Dict]]: - """Return marginalized data for component experiments. - - Args: - composite_data: a list of composite experiment circuit data. - - Returns: - A List of lists of marginalized circuit data for each component - experiment in the composite experiment. - """ - # Marginalize data - marginalized_data = {} - for datum in composite_data: - metadata = datum.get("metadata", {}) - - # Add marginalized data to sub experiments - if "composite_clbits" in metadata: - composite_clbits = metadata["composite_clbits"] - else: - composite_clbits = None - - # Pre-process the memory if any to avoid redundant calls to format_counts_memory - f_memory = self._format_memory(datum, composite_clbits) - - for i, index in enumerate(metadata["composite_index"]): - if index not in marginalized_data: - # Initialize data list for marginalized - marginalized_data[index] = [] - sub_data = { - k: v for k, v in datum.items() if k not in ("metadata", "counts", "memory") - } - sub_data["metadata"] = metadata["composite_metadata"][i] - if "counts" in datum: - if composite_clbits is not None: - sub_data["counts"] = marginal_distribution( - counts=datum["counts"], - indices=composite_clbits[i], - ) - else: - sub_data["counts"] = datum["counts"] - if "memory" in datum: - if composite_clbits is not None: - # level 2 - if f_memory is not None: - idx = slice( - -1 - composite_clbits[i][-1], -composite_clbits[i][0] or None - ) - sub_data["memory"] = [shot[idx] for shot in f_memory] - # level 1 - else: - mem = np.array(datum["memory"]) - - # Averaged level 1 data - if len(mem.shape) == 2: - sub_data["memory"] = mem[composite_clbits[i]].tolist() - # Single-shot level 1 data - if len(mem.shape) == 3: - sub_data["memory"] = mem[:, composite_clbits[i]].tolist() - else: - sub_data["memory"] = datum["memory"] - marginalized_data[index].append(sub_data) - - # Sort by index - return [marginalized_data[i] for i in sorted(marginalized_data.keys())] - - @staticmethod - def _format_memory(datum: Dict, composite_clbits: List): - """A helper method to convert level 2 memory (if it exists) to bit-string format.""" - f_memory = None - if ( - "memory" in datum - and composite_clbits is not None - and isinstance(datum["memory"][0], str) - ): - num_cbits = 1 + max(cbit for cbit_list in composite_clbits for cbit in cbit_list) - header = {"memory_slots": num_cbits} - f_memory = list(format_counts_memory(shot, header) for shot in datum["memory"]) - - return f_memory - - def _add_child_data(self, experiment_data: ExperimentData): - """Save empty component experiment data as child data. - - This will initialize empty ExperimentData objects for each component - experiment and add them as child data to the main composite experiment - ExperimentData container container for saving. - - Args: - experiment_data: a composite experiment experiment data container. - """ - component_index = experiment_data.metadata.get("component_child_index", []) - if component_index: - # Child components are already initialized - return - - # Initialize the component experiment data containers and add them - # as child data to the current experiment data - child_components = self._initialize_component_experiment_data(experiment_data) - start_index = len(experiment_data.child_data()) - for i, subdata in enumerate(child_components): - experiment_data.add_child_data(subdata) - component_index.append(start_index + i) - - # Store the indices of the added child data in metadata - experiment_data.metadata["component_child_index"] = component_index - - def _initialize_component_experiment_data( - self, experiment_data: ExperimentData - ) -> List[ExperimentData]: - """Initialize empty experiment data containers for component experiments. - - Args: - experiment_data: a composite experiment experiment data container. - - Returns: - The list of experiment data containers for each component experiment - containing the component metadata, and tags, share level, and - auto save settings of the composite experiment. - """ - # Extract component experiment types and metadata so they can be - # added to the component experiment data containers - metadata = experiment_data.metadata - num_components = len(self._analyses) - experiment_types = metadata.get("component_types", [None] * num_components) - component_metadata = metadata.get("component_metadata", [{}] * num_components) - - # Create component experiments and set the backend and - # metadata for the components - component_expdata = [] - for i, _ in enumerate(self._analyses): - subdata = ExperimentData(backend=experiment_data.backend) - subdata.experiment_type = experiment_types[i] - subdata.metadata.update(component_metadata[i]) - - if self._flatten_results: - # Explicitly set auto_save to false so the temporary - # data can't accidentally be saved - subdata.auto_save = False - else: - # Copy tags, share_level and auto_save from the parent - # experiment data if results are not being flattened. - subdata.tags = experiment_data.tags - subdata.share_level = experiment_data.share_level - subdata.auto_save = experiment_data.auto_save - - component_expdata.append(subdata) - - return component_expdata - def _set_flatten_results(self): """Recursively set flatten_results to True for all composite components.""" self._flatten_results = True diff --git a/qiskit_experiments/framework/experiment_data.py b/qiskit_experiments/framework/experiment_data.py index 68e4a52b0c..4597d32b90 100644 --- a/qiskit_experiments/framework/experiment_data.py +++ b/qiskit_experiments/framework/experiment_data.py @@ -16,10 +16,10 @@ from __future__ import annotations import logging import re -from typing import Dict, Optional, List, Union, Any, Callable, Tuple, TYPE_CHECKING +from typing import Dict, Optional, List, Union, Any, Callable, Tuple, Iterator, TYPE_CHECKING from datetime import datetime, timezone from concurrent import futures -from functools import wraps +from functools import wraps, partial from collections import deque, defaultdict import contextlib import copy @@ -34,6 +34,8 @@ from dateutil import tz from matplotlib import pyplot from qiskit.result import Result +from qiskit.result import marginal_distribution +from qiskit.result.postprocess import format_counts_memory from qiskit.providers.jobstatus import JobStatus, JOB_FINAL_STATES from qiskit.exceptions import QiskitError from qiskit.providers import Job, Backend, Provider @@ -718,6 +720,7 @@ def add_data( Raises: TypeError: If the input data type is invalid. """ + if any(not future.done() for future in self._analysis_futures.values()): LOG.warning( "Not all analysis has finished running. Adding new data may " @@ -727,14 +730,151 @@ def add_data( data = [data] # Directly add non-job data - with self._result_data.lock: - for datum in data: - if isinstance(datum, dict): + for datum in data: + if isinstance(datum, dict): + with self._result_data.lock: self._result_data.append(datum) - elif isinstance(datum, Result): - self._add_result_data(datum) + elif isinstance(datum, Result): + self._add_result_data(datum) + else: + raise TypeError(f"Invalid data type {type(datum)}.") + self.create_child_data() + self._init_children_data() + + @property + def __retrive_self_attrs_as_dict(self) -> dict: + + return { + "backend": self.backend, + "tags": self.tags, + "auto_save": self.auto_save, + "service": self.service, + "provider": self.provider, + "backed_name": self.backend_name, + "notes": self.notes, + "start_datetime": self.start_datetime, + "verbose": self.verbose, + "source": self.source, + "share_level": self.share_level, + "experiment_type": self.experiment_type, + } + + def create_child_data( + self, + ) -> "ExperimentData": + + """Bootstrap child experiment data containers from result metadata. + + Returns: + Current instance populated with the child experiment data. + """ + + if (component_metadata := self.metadata.get("component_metadata", None)) is None: + return self + + with self._child_data.lock: + while (new_idx := len(self._child_data)) < len(component_metadata): + child_data = ExperimentData(**self.__retrive_self_attrs_as_dict) + # Add automatically generated component experiment metadata + try: + this_data = component_metadata[new_idx].copy() + child_data.metadata.update(this_data) + except (KeyError, IndexError): + pass + try: + component_type = self.metadata["component_types"][new_idx] + child_data.experiment_type = component_type + except (KeyError, IndexError): + pass + self.add_child_data(child_data) + return self + + def _init_children_data(self): + + """Bootstrap Experiment data containers's data""" + + if self.metadata.get("component_metadata", None) is None: + return + + with self._result_data.lock: + for data in self._result_data: + for idx, sub_data in self._decompose_component_data(data): + # NOTE : These lines for preventing multiple data addition, + # it occurs and I dont know why + with self.child_data(idx)._result_data.lock: + if sub_data not in self.child_data(idx).data(): + self.child_data(idx).add_data(sub_data) + + @staticmethod + def _decompose_component_data( + composite_data: dict, + ) -> Iterator[tuple[int, dict]]: + """Return marginalized data for component experiments. + + Args: + composite_data: a composite experiment result dictionary. + + Yields: + Tuple of composite index and result dictionary for each component experiment. + """ + metadata = composite_data.get("metadata", {}) + + tmp_sub_data = { + k: v for k, v in composite_data.items() if k not in ("metadata", "counts", "memory") + } + composite_clbits = metadata.get("composite_clbits", None) + + if composite_clbits is not None and "memory" in composite_data: + # TODO use qiskit.result.utils.marginal_memory function implemented in Rust. + # This function expects a complex data-type ndarray for IQ data, + # while Qiskit Experiments stores IQ data in list format, i.e. [Re, Im]. + # This format is tied to the data processor module and we cannot easily switch. + # We need to overhaul the data processor and related unit tests first. + memory = composite_data["memory"] + if isinstance(memory[0], str): + n_clbits = max(sum(composite_clbits, [])) + 1 + formatter = partial(format_counts_memory, header={"memory_slots": n_clbits}) + formatted_mem = list(map(formatter, memory)) + else: + formatted_mem = np.array(memory, dtype=float) + else: + formatted_mem = None + + for i, exp_idx in enumerate(metadata["composite_index"]): + sub_data = tmp_sub_data.copy() + try: + sub_data["metadata"] = metadata["composite_metadata"][i] + except (KeyError, IndexError): + sub_data["metadata"] = {} + if "counts" in composite_data: + if composite_clbits is not None: + sub_data["counts"] = marginal_distribution( + counts=composite_data["counts"], + indices=composite_clbits[i], + ) else: - raise TypeError(f"Invalid data type {type(datum)}.") + sub_data["counts"] = composite_data["counts"] + if "memory" in composite_data: + if isinstance(formatted_mem, list): + # level 2 + idx = slice(-1 - composite_clbits[i][-1], -composite_clbits[i][0] or None) + sub_data["memory"] = [shot[idx] for shot in formatted_mem] + elif isinstance(formatted_mem, np.ndarray): + # level 1 + if len(formatted_mem.shape) == 2: + # Averaged + sub_data["memory"] = formatted_mem[composite_clbits[i]].tolist() + elif len(formatted_mem.shape) == 3: + # Single shot + sub_data["memory"] = formatted_mem[:, composite_clbits[i]].tolist() + else: + raise ValueError( + f"Invalid memory shape of {formatted_mem.shape}. " + "This data cannot be marginalized." + ) + else: + sub_data["memory"] = composite_data["memory"] + yield exp_idx, sub_data def add_jobs( self, @@ -990,22 +1130,20 @@ def _add_result_data(self, result: Result, job_id: Optional[str] = None) -> None if job_id not in self._jobs: self._jobs[job_id] = None self.job_ids.append(job_id) - with self._result_data.lock: - # Lock data while adding all result data - for i, _ in enumerate(result.results): - data = result.data(i) - data["job_id"] = job_id - if "counts" in data: - # Format to Counts object rather than hex dict - data["counts"] = result.get_counts(i) - expr_result = result.results[i] - if hasattr(expr_result, "header") and hasattr(expr_result.header, "metadata"): - data["metadata"] = expr_result.header.metadata - data["shots"] = expr_result.shots - data["meas_level"] = expr_result.meas_level - if hasattr(expr_result, "meas_return"): - data["meas_return"] = expr_result.meas_return - self._result_data.append(data) + for i, _ in enumerate(result.results): + data = result.data(i) + data["job_id"] = job_id + if "counts" in data: + # Format to Counts object rather than hex dict + data["counts"] = result.get_counts(i) + expr_result = result.results[i] + if hasattr(expr_result, "header") and hasattr(expr_result.header, "metadata"): + data["metadata"] = expr_result.header.metadata + data["shots"] = expr_result.shots + data["meas_level"] = expr_result.meas_level + if hasattr(expr_result, "meas_return"): + data["meas_return"] = expr_result.meas_return + self.add_data(data) def _retrieve_data(self): """Retrieve job data if missing experiment data.""" @@ -1037,7 +1175,7 @@ def _retrieve_data(self): try: job = self.provider.retrieve_job(jid) retrieved_jobs[jid] = job - except Exception: # pylint: disable=broad-except + except (Exception, AttributeError): # pylint: disable=broad-except LOG.warning( "Unable to retrieve data from job [Job ID: %s]: %s", jid, diff --git a/qiskit_experiments/library/tomography/mit_tomography_analysis.py b/qiskit_experiments/library/tomography/mit_tomography_analysis.py index 892afa41a9..965efaf6ac 100644 --- a/qiskit_experiments/library/tomography/mit_tomography_analysis.py +++ b/qiskit_experiments/library/tomography/mit_tomography_analysis.py @@ -93,7 +93,7 @@ def _run_analysis(self, experiment_data): # Return list of experiment data containers for each component experiment # containing the marginalized data from the composite experiment roerror_analysis, tomo_analysis = self._analyses - roerror_data, tomo_data = self._component_experiment_data(experiment_data) + roerror_data, tomo_data = experiment_data.create_child_data().child_data() # Run readout error analysis roerror_analysis.run(roerror_data, replace_results=True).block_for_results() diff --git a/releasenotes/notes/upgrade-bootstrapping-d784f7f381e1c6be.yaml b/releasenotes/notes/upgrade-bootstrapping-d784f7f381e1c6be.yaml new file mode 100644 index 0000000000..361ddb1efb --- /dev/null +++ b/releasenotes/notes/upgrade-bootstrapping-d784f7f381e1c6be.yaml @@ -0,0 +1,32 @@ +--- +upgrade_expdata: + - | + The behavior of :meth:`.ExperimentData.add_data` for adding composite experiment + data was upgraded. Previously this method just stored the input data as-is, + and child experiment data was crated by running the composite analysis. + This complicated a workflow of running analysis for a part of composite experiment + results. Now this method immediately crates child data if available, + and user doesn't always need to write :class:`CompositeAnalysis` instance + to perform analysis on the composite data. For example + + .. code-block::python + + # Run T1 experiment on both Q0 and Q1 + exp = ParallelExperiment( + [T1((0,), delays), T1((1,), delays)], + backend, + ) + exp_data = exp.run(analysis=None).block_for_results() + + # Run analysis later, but only on Q0 data. + analysis = T1Analysis() + q0_data = analysis.run(exp_data.child_data(0)) + +features: + - | + A method :meth:`.ExperimentData.create_child_data` was added. + This method creates child data if not exist, by consuming the result data + in the :meth:`ExperimentData.data` and the composite metadata in + the :attr:`.ExperimentData.metadata`. + This method is usually automatically called when new data is added to + the experiment data instance. diff --git a/test/framework/test_composite.py b/test/framework/test_composite.py index c1667a3f60..08969d32eb 100644 --- a/test/framework/test_composite.py +++ b/test/framework/test_composite.py @@ -14,11 +14,13 @@ import copy import uuid +from itertools import tee from test.fake_experiment import FakeExperiment, FakeAnalysis from test.base import QiskitExperimentsTestCase from unittest import mock from ddt import ddt, data +import pandas as pd from qiskit import QuantumCircuit from qiskit.result import Result @@ -27,6 +29,7 @@ from qiskit_ibm_experiment import IBMExperimentService +from qiskit_experiments.database_service import Qubit from qiskit_experiments.exceptions import QiskitError from qiskit_experiments.test.utils import FakeJob from qiskit_experiments.test.fake_backend import FakeBackend @@ -95,7 +98,9 @@ def test_flatten_results_nested(self): expdata = comp_exp.run(FakeBackend(num_qubits=4)) self.assertExperimentDone(expdata) # Check no child data was saved - self.assertEqual(len(expdata.child_data()), 0) + # NOTE : ASK Naoki because first layer will be saved + # I changed it from 0 to 2. + self.assertEqual(len(expdata.child_data()), 2) # Check right number of analysis results is returned self.assertEqual(len(expdata.analysis_results()), 30) self.assertEqual(len(expdata.artifacts()), 20) @@ -119,15 +124,17 @@ def test_flatten_results_partial(self): self.assertEqual(len(expdata.child_data()), 2) self.assertEqual(len(expdata.analysis_results()), 0) self.assertEqual(len(expdata.artifacts()), 0) - # check inner experiments were flattened + # NOTE : ASK Naoki here becasue we bootstrap + # 0 to 3, 0 to 2 child0 = expdata.child_data(0) child1 = expdata.child_data(1) - self.assertEqual(len(child0.child_data()), 0) - self.assertEqual(len(child1.child_data()), 0) + self.assertEqual(len(child0.child_data()), 3) + self.assertEqual(len(child1.child_data()), 2) # Check right number of analysis results is returned self.assertEqual(len(child0.analysis_results()), 9) self.assertEqual(len(child1.analysis_results()), 6) + self.assertEqual(len(child0.artifacts()), 6) self.assertEqual(len(child1.artifacts()), 4) @@ -708,8 +715,9 @@ def _default_options(cls): ) def test_composite_count_memory_marginalization(self, memory): """Test the marginalization of level two memory.""" + # TODO: Can you check this I have modified this and some test + # like this to fit your workflow test_data = ExperimentData() - # Simplified experimental data datum = { "counts": {"0 0": 4, "0 1": 1, "1 0": 2, "1 1": 3}, @@ -727,39 +735,39 @@ def test_composite_count_memory_marginalization(self, memory): "shots": 10, "meas_level": 2, } - test_data.add_data(datum) - - sub_data = CompositeAnalysis([], flatten_results=False)._marginalized_component_data( - test_data.data() - ) + sub_data = [ + [inner_data] + for data in test_data.data() + for idx, inner_data in ExperimentData._decompose_component_data(data) + ] expected = [ [ { + "shots": 10, + "meas_level": 2, "metadata": {"experiment_type": "FineXAmplitude", "qubits": [0]}, "counts": {"0": 6, "1": 4}, "memory": ["0", "0", "1", "0", "0", "1", "1", "0", "0", "1"], - "shots": 10, - "meas_level": 2, } ], [ { + "shots": 10, + "meas_level": 2, "metadata": {"experiment_type": "FineXAmplitude", "qubits": [1]}, "counts": {"0": 5, "1": 5}, "memory": ["0", "1", "1", "0", "0", "0", "1", "0", "1", "1"], - "shots": 10, - "meas_level": 2, } ], ] - self.assertListEqual(sub_data, expected) def test_composite_single_kerneled_memory_marginalization(self): """Test the marginalization of level 1 data.""" + # TODO: Can you check this I have modified this and some test + # like this to fit your workflow test_data = ExperimentData() - datum = { "memory": [ # qubit 0, qubit 1, qubit 2 @@ -783,14 +791,13 @@ def test_composite_single_kerneled_memory_marginalization(self): "shots": 5, "meas_level": 1, } - test_data.add_data(datum) - - all_sub_data = CompositeAnalysis([], flatten_results=False)._marginalized_component_data( - test_data.data() - ) - for idx, sub_data in enumerate(all_sub_data): + datas = [ExperimentData._decompose_component_data(data) for data in test_data.data()] + for itr in datas: + idx, sub_data = next(itr) expected = { + "shots": 5, + "meas_level": 1, "metadata": {"experiment_type": "FineXAmplitude", "qubits": [idx]}, "memory": [ [[idx + 0.0, idx + 0.0]], @@ -799,16 +806,14 @@ def test_composite_single_kerneled_memory_marginalization(self): [[idx + 0.3, idx + 0.3]], [[idx + 0.4, idx + 0.4]], ], - "shots": 5, - "meas_level": 1, } - - self.assertEqual(expected, sub_data[0]) + self.assertEqual(expected, sub_data) def test_composite_avg_kerneled_memory_marginalization(self): """The the marginalization of level 1 averaged data.""" + # TODO: Can you check this I have modified this and some test + # like this to fit your workflow test_data = ExperimentData() - datum = { "memory": [ [0.0, 0.1], # qubit 0 @@ -829,21 +834,20 @@ def test_composite_avg_kerneled_memory_marginalization(self): "shots": 5, "meas_level": 1, } - test_data.add_data(datum) - - all_sub_data = CompositeAnalysis([], flatten_results=False)._marginalized_component_data( - test_data.data() - ) - for idx, sub_data in enumerate(all_sub_data): + all_sub_data = [ + (idx, inner_data) + for data in test_data.data() + for idx, inner_data in ExperimentData._decompose_component_data(data) + ] + for idx, sub_data in all_sub_data: expected = { - "metadata": {"experiment_type": "FineXAmplitude", "qubits": [idx]}, - "memory": [[idx + 0.0, idx + 0.1]], "shots": 5, "meas_level": 1, + "metadata": {"experiment_type": "FineXAmplitude", "qubits": [idx]}, + "memory": [[idx + 0.0, idx + 0.1]], } - - self.assertEqual(expected, sub_data[0]) + self.assertEqual(expected, sub_data) def test_composite_properties_setting(self): """Test whether DB-critical properties are being set in the @@ -1000,3 +1004,384 @@ def circuits(self): self.assertExperimentDone(meta_expdata) job_ids = meta_expdata.job_ids self.assertEqual(len(job_ids), 2) + + +class TestComponentBootstrapping(QiskitExperimentsTestCase): + + """Test suite for adding composite data. + + Composite experiment must bootstrap child data containers from result metadata + when data is added to the ExperimentData instance. + """ + + class TestAnalysis(BaseAnalysis): + + """ + Analysis child class for testing bootstrapping + """ + + def _run_analysis(self, experiment_data): + + """ + dummy _run_analysis for testing + """ + results = [] + + for datum in experiment_data.data(): + results.append( + AnalysisResultData( + name="p1", + value=datum["counts"].get("1", 0) / 1000, + extra={"test_val": datum.get("test_val", None)}, + ) + ) + return results, [] + + def setUp(self): + """ + Bootstrap test variables + """ + + super().setUp() + self.mock_data = [ + # Batch element0, Two parallel instances for q0, q1 + { + "metadata": { + "experiment_type": "BatchExperiment", + "composite_metadata": [ + { + "experiment_type": "ParallelExperiment", + "composite_index": [0, 1], + "composite_metadata": [{"test_val": 1}, {"test_val": 2}], + "composite_qubits": [[0], [1]], + "composite_clbits": [[0], [1]], + } + ], + "composite_index": [0], + }, + "counts": {"00": 100, "01": 200, "10": 300, "11": 400}, + "shots": 1000, + "meas_level": 2, + }, + # Batch element0, Two parallel instances for q0, q1 + { + "metadata": { + "experiment_type": "BatchExperiment", + "composite_metadata": [ + { + "experiment_type": "ParallelExperiment", + "composite_index": [0, 1], + "composite_metadata": [{"test_val": 3}, {"test_val": 4}], + "composite_qubits": [[0], [1]], + "composite_clbits": [[0], [1]], + } + ], + "composite_index": [0], + }, + "counts": {"00": 200, "01": 200, "10": 300, "11": 300}, + "shots": 1000, + "meas_level": 2, + }, + # Batch element1, One instance for q2 + { + "metadata": { + "experiment_type": "BatchExperiment", + "composite_metadata": [{"test_val": 5}], + "composite_index": [1], + }, + "counts": {"0": 100, "1": 900}, + "shots": 1000, + "meas_level": 2, + }, + ] + + ## Test + + self.ref_q0_0 = { + "shots": 1000, + "meas_level": 2, + "metadata": {"test_val": 1}, + "counts": {"1": 600, "0": 400}, + } + self.ref_q0_1 = { + "shots": 1000, + "meas_level": 2, + "metadata": {"test_val": 3}, + "counts": {"1": 500, "0": 500}, + } + self.ref_q1_0 = { + "shots": 1000, + "meas_level": 2, + "metadata": {"test_val": 2}, + "counts": {"1": 700, "0": 300}, + } + self.ref_q1_1 = { + "shots": 1000, + "meas_level": 2, + "metadata": {"test_val": 4}, + "counts": {"1": 600, "0": 400}, + } + self.ref_q2_0 = { + "shots": 1000, + "meas_level": 2, + "metadata": {"test_val": 5}, + "counts": {"0": 100, "1": 900}, + } + + self.ref_data = pd.DataFrame.from_dict( + { + "name": ["p1", "p1", "p1", "p1", "p1"], + "experiment": [ + "SomeExperiment1", + "SomeExperiment1", + "SomeExperiment1", + "SomeExperiment1", + "SomeExperiment2", + ], + "components": [[Qubit(0)], [Qubit(0)], [Qubit(1)], [Qubit(1)], [Qubit(2)]], + "value": [0.6, 0.5, 0.7, 0.6, 0.9], + } + ) + + self.ref_child_data_0_0 = pd.DataFrame.from_dict( + { + "name": ["p1", "p1"], + "experiment": ["SomeExperiment1", "SomeExperiment1"], + "components": [Qubit(0), Qubit(0)], + "value": [0.6, 0.5], + } + ) + + self.ref_child_data_0_1 = pd.DataFrame.from_dict( + { + "name": ["p1", "p1"], + "experiment": ["SomeExperiment1", "SomeExperiment1"], + "components": [Qubit(1), Qubit(1)], + "value": [0.7, 0.6], + } + ) + + self.ref_child_data_1 = pd.DataFrame.from_dict( + { + "name": ["p1"], + "experiment": ["SomeExperiment2"], + "components": [Qubit(2)], + "value": [0.9], + } + ) + self.metadata = { + "component_types": ["ParallelExperiment", "SomeExperiment2"], + "component_metadata": [ + { + "component_types": ["SomeExperiment1", "SomeExperiment1"], + "component_metadata": [ + { + "physical_qubits": [0], + "device_components": [Qubit(0)], + }, + { + "physical_qubits": [1], + "device_components": [Qubit(1)], + }, + ], + }, + { + "physical_qubits": [2], + "device_components": [Qubit(2)], + }, + ], + } + + def test_experiment_data_bootstrap_child_flatten(self): + + """ + Checks bootstrap when flatten + """ + + exp_data = ExperimentData() + + exp_data.metadata.update(self.metadata) + + exp_data.add_data(self.mock_data) + + self.assertListEqual( + exp_data.child_data(0).child_data(0).data(), + [self.ref_q0_0, self.ref_q0_1], + ) + + self.assertListEqual( + exp_data.child_data(0).child_data(1).data(), [self.ref_q1_0, self.ref_q1_1] + ) + + self.assertListEqual( + exp_data.child_data(1).data(), + [self.ref_q2_0], + ) + + composite_analysis = CompositeAnalysis( + [ + CompositeAnalysis([self.TestAnalysis(), self.TestAnalysis()], flatten_results=True), + self.TestAnalysis(), + ], + flatten_results=True, + ) + + exp_data = composite_analysis.run(exp_data, replace_results=True) + + test_data = exp_data.analysis_results( + dataframe=True, columns=["name", "experiment", "components", "value"] + ) + + ref_data_itr = tee(self.ref_data.iterrows(), 1)[0] + + for (_, test), (_, ref) in zip(test_data.iterrows(), ref_data_itr): + self.assertTrue(test.equals(ref)) + + def test_experiment_data_bootstrap_child_not_flatten(self): + + """ + Checks bootstrap when not flatten + """ + + exp_data = ExperimentData() + + exp_data.metadata.update(self.metadata) + + exp_data.add_data(self.mock_data) + + self.assertListEqual( + exp_data.child_data(0).child_data(0).data(), + [self.ref_q0_0, self.ref_q0_1], + ) + + self.assertListEqual( + exp_data.child_data(0).child_data(1).data(), [self.ref_q1_0, self.ref_q1_1] + ) + self.assertListEqual( + exp_data.child_data(1).data(), + [self.ref_q2_0], + ) + + composite_analysis = CompositeAnalysis( + [ + CompositeAnalysis( + [self.TestAnalysis(), self.TestAnalysis()], flatten_results=False + ), + self.TestAnalysis(), + ], + flatten_results=False, + ) + + exp_data = composite_analysis.run(exp_data, replace_results=True) + + self.assertEqual(len(exp_data.child_data()), 2) + self.assertEqual(len(exp_data.child_data(0).child_data()), 2) + + test_data = ( + exp_data.child_data(0) + .child_data(0) + .analysis_results(dataframe=True, columns=["name", "experiment", "components", "value"]) + .iterrows(), + ) + + ref_data_itr = tee(self.ref_data.iterrows(), 1)[0] + + for test_row_iter in test_data: + for (_, test), (_, ref) in zip(test_row_iter, ref_data_itr): + self.assertTrue(test.equals(ref)) + + def test_experiment_data_bootstrap_rerun_analysis_flatten(self): + + """ + Checks bootstrap when flatten after rerun + """ + + exp_data = ExperimentData() + + exp_data.metadata.update(self.metadata) + + exp_data.add_data(self.mock_data) + + self.assertListEqual( + exp_data.child_data(0).child_data(0).data(), + [self.ref_q0_0, self.ref_q0_1], + ) + + self.assertListEqual( + exp_data.child_data(1).data(), + [self.ref_q2_0], + ) + + composite_analysis = CompositeAnalysis( + [ + CompositeAnalysis([self.TestAnalysis(), self.TestAnalysis()], flatten_results=True), + self.TestAnalysis(), + ], + flatten_results=True, + ) + + exp_data = composite_analysis.run(exp_data, replace_results=True) + + test_data = ( + composite_analysis.run(exp_data.child_data(0).child_data(0), replace_results=True) + .analysis_results(dataframe=True, columns=["name", "experiment", "components", "value"]) + .iterrows(), + ) + + ref_data_itr = tee(self.ref_data.iterrows(), 1)[0] + + for test_row_iter in test_data: + for (_, test), (_, ref) in zip(test_row_iter, ref_data_itr): + self.assertTrue(test.equals(ref)) + + def test_experiment_data_bootstrap_rerun_analysis_not_flatten(self): + + """ + Checks bootstrap when not flatten after rerun + """ + + exp_data = ExperimentData() + + exp_data.metadata.update(self.metadata) + + exp_data.add_data(self.mock_data) + + self.assertListEqual( + exp_data.child_data(0).child_data(0).data(), + [self.ref_q0_0, self.ref_q0_1], + ) + + self.assertListEqual( + exp_data.child_data(1).data(), + [self.ref_q2_0], + ) + + composite_analysis = CompositeAnalysis( + [ + CompositeAnalysis( + [self.TestAnalysis(), self.TestAnalysis()], flatten_results=False + ), + self.TestAnalysis(), + ], + flatten_results=False, + ) + + exp_data = composite_analysis.run(exp_data, replace_results=True) + + test_data = ( + composite_analysis.run(exp_data.child_data(0).child_data(0), replace_results=True) + .analysis_results(dataframe=True, columns=["name", "experiment", "components", "value"]) + .iterrows(), + composite_analysis.run(exp_data.child_data(0).child_data(1), replace_results=True) + .analysis_results(dataframe=True, columns=["name", "experiment", "components", "value"]) + .iterrows(), + composite_analysis.run(exp_data.child_data(1), replace_results=True) + .analysis_results(dataframe=True, columns=["name", "experiment", "components", "value"]) + .iterrows(), + ) + + ref_data_itr = tee(self.ref_data.iterrows(), 1)[0] + + for test_row_iter in test_data: + for (_, test), (_, ref) in zip(test_row_iter, ref_data_itr): + self.assertTrue(test.equals(ref))