diff --git a/qiskit_experiments/curve_analysis/curve_analysis.py b/qiskit_experiments/curve_analysis/curve_analysis.py index 53ccb1b116..2e15010783 100644 --- a/qiskit_experiments/curve_analysis/curve_analysis.py +++ b/qiskit_experiments/curve_analysis/curve_analysis.py @@ -47,6 +47,7 @@ ExperimentData, AnalysisResultData, Options, + AnalysisConfig, ) PARAMS_ENTRY_PREFIX = "@Parameters_" @@ -233,13 +234,24 @@ class AnalysisExample(CurveAnalysis): #: List[SeriesDef]: List of mapping representing a data series __series__ = list() - #: List[str]: Fixed parameter in fit function. Value should be set to the analysis options. - __fixed_parameters__ = list() - def __init__(self): """Initialize data fields that are privately accessed by methods.""" super().__init__() + if hasattr(self, "__fixed_parameters__"): + warnings.warn( + "The class attribute __fixed_parameters__ has been deprecated and will be removed. " + "Now this attribute is absorbed in analysis options as fixed_parameters. " + "This warning will be dropped in v0.4 along with " + "the support for the deprecated attribute.", + DeprecationWarning, + stacklevel=2, + ) + # pylint: disable=no-member + self._options.fixed_parameters = { + p: self.options.get(p, None) for p in self.__fixed_parameters__ + } + #: Dict[str, Any]: Experiment metadata self.__experiment_metadata = None @@ -271,21 +283,12 @@ def _fit_params(cls) -> List[str]: ) # remove the first function argument. this is usually x, i.e. not a fit parameter. - fit_params = list(list(fsigs)[0].parameters.keys())[1:] - - # remove fixed parameters - if cls.__fixed_parameters__ is not None: - for fixed_param in cls.__fixed_parameters__: - try: - fit_params.remove(fixed_param) - except ValueError as ex: - raise AnalysisError( - f"Defined fixed parameter {fixed_param} is not a fit function argument." - "Update series definition to ensure the parameter name is defined with " - f"fit functions. Currently available parameters are {fit_params}." - ) from ex - - return fit_params + return list(list(fsigs)[0].parameters.keys())[1:] + + @property + def parameters(self) -> List[str]: + """Return parameters of this curve analysis.""" + return [s for s in self._fit_params() if s not in self.options.fixed_parameters] @classmethod def _default_options(cls) -> Options: @@ -339,6 +342,9 @@ def _default_options(cls) -> Options: as extra information. curve_fitter_options (Dict[str, Any]) Options that are passed to the specified curve fitting function. + fixed_parameters (Dict[str, Any]): Fitting model parameters that are fixed + during the curve fitting. This should be provided with default value + keyed on one of the parameter names in the series definition. """ options = super()._default_options() @@ -360,11 +366,9 @@ def _default_options(cls) -> Options: options.style = PlotterStyle() options.extra = dict() options.curve_fitter_options = dict() - - # automatically populate initial guess and boundary - fit_params = cls._fit_params() - options.p0 = {par_name: None for par_name in fit_params} - options.bounds = {par_name: None for par_name in fit_params} + options.p0 = {} + options.bounds = {} + options.fixed_parameters = {} return options @@ -754,16 +758,15 @@ def _run_analysis( # # Update all fit functions in the series definitions if fixed parameter is defined. - # Fixed parameters should be provided by the analysis options. - if self.__fixed_parameters__: - assigned_params = {k: self.options.get(k, None) for k in self.__fixed_parameters__} + assigned_params = self.options.fixed_parameters + if assigned_params: # Check if all parameters are assigned. if any(v is None for v in assigned_params.values()): raise AnalysisError( f"Unassigned fixed-value parameters for the fit " f"function {self.__class__.__name__}." - f"All values of fixed-parameters, i.e. {self.__fixed_parameters__}, " + f"All values of fixed-parameters, i.e. {assigned_params}, " "must be provided by the analysis options to run this analysis." ) @@ -815,7 +818,7 @@ def _run_analysis( # Generate algorithmic initial guesses and boundaries default_fit_opt = FitOptions( - parameters=self._fit_params(), + parameters=self.parameters, default_p0=self.options.p0, default_bounds=self.options.bounds, **self.options.curve_fitter_options, @@ -964,6 +967,35 @@ def _run_analysis( return analysis_results, figures + @classmethod + def from_config(cls, config: Union[AnalysisConfig, Dict]) -> "CurveAnalysis": + # For backward compatibility. This will be removed in v0.4. + + instance = super().from_config(config) + + # When fixed param value is hard-coded as options. This is deprecated data structure. + loaded_opts = instance.options.__dict__ + + # pylint: disable=no-member + deprecated_fixed_params = { + p: loaded_opts[p] for p in instance.parameters if p in loaded_opts + } + if any(deprecated_fixed_params): + warnings.warn( + "Fixed parameter value should be defined in options.fixed_parameters as " + "a dictionary values, rather than a standalone analysis option. " + "Please re-save this experiment to be loaded after deprecation period. " + "This warning will be dropped in v0.4 along with " + "the support for the deprecated fixed parameter options.", + DeprecationWarning, + stacklevel=2, + ) + new_fixed_params = instance.options.fixed_parameters + new_fixed_params.update(deprecated_fixed_params) + instance.set_options(fixed_parameters=new_fixed_params) + + return instance + def is_error_not_significant( val: Union[float, uncertainties.UFloat], diff --git a/qiskit_experiments/curve_analysis/standard_analysis/error_amplification_analysis.py b/qiskit_experiments/curve_analysis/standard_analysis/error_amplification_analysis.py index 498b4d30ab..a863fe3998 100644 --- a/qiskit_experiments/curve_analysis/standard_analysis/error_amplification_analysis.py +++ b/qiskit_experiments/curve_analysis/standard_analysis/error_amplification_analysis.py @@ -105,12 +105,6 @@ def _default_options(cls): descriptions of analysis options. Analysis Options: - angle_per_gate (float): The ideal angle per repeated gate. - The user must set this option as it defaults to None. - phase_offset (float): A phase offset for the analysis. This phase offset will be - :math:`\pi/2` if the square-root of X gate is added before the repeated gates. - This is decided for the user in :meth:`set_schedule` depending on whether the - sx gate is included in the experiment. max_good_angle_error (float): The maximum angle error for which the fit is considered as good. Defaults to :math:`\pi/2`. """ @@ -118,11 +112,8 @@ def _default_options(cls): default_options.result_parameters = ["d_theta"] default_options.xlabel = "Number of gates (n)" default_options.ylabel = "Population" - default_options.angle_per_gate = None - default_options.phase_offset = 0.0 - default_options.max_good_angle_error = np.pi / 2 - default_options.amp = 1.0 default_options.ylim = [0, 1.0] + default_options.max_good_angle_error = np.pi / 2 return default_options @@ -140,6 +131,8 @@ def _generate_fit_guesses( Raises: CalibrationError: When ``angle_per_gate`` is missing. """ + fixed_params = self.options.fixed_parameters + curve_data = self._data() max_abs_y, _ = curve.guess.max_height(curve_data.y, absolute=True) max_y, min_y = np.max(curve_data.y), np.min(curve_data.y) @@ -152,16 +145,19 @@ def _generate_fit_guesses( if "amp" in user_opt.p0: user_opt.p0.set_if_empty(amp=max_y - min_y) user_opt.bounds.set_if_empty(amp=(0, 2 * max_abs_y)) + amp = user_opt.p0["amp"] + else: + # Fixed parameter + amp = fixed_params.get("amp", 1.0) # Base the initial guess on the intended angle_per_gate and phase offset. - apg = self.options.angle_per_gate - phi = self.options.phase_offset + apg = user_opt.p0.get("angle_per_gate", fixed_params.get("angle_per_gate", 0.0)) + phi = user_opt.p0.get("phase_offset", fixed_params.get("phase_offset", 0.0)) # Prepare logical guess for specific condition (often satisfied) d_theta_guesses = [] offsets = apg * curve_data.x + phi - amp = user_opt.p0.get("amp", self.options.amp) for i in range(curve_data.x.size): xi = curve_data.x[i] yi = curve_data.y[i] diff --git a/qiskit_experiments/library/calibration/fine_amplitude.py b/qiskit_experiments/library/calibration/fine_amplitude.py index 170fa577dc..939e595b18 100644 --- a/qiskit_experiments/library/calibration/fine_amplitude.py +++ b/qiskit_experiments/library/calibration/fine_amplitude.py @@ -171,9 +171,10 @@ def __init__( auto_update=auto_update, ) self.analysis.set_options( - angle_per_gate=np.pi, - phase_offset=np.pi / 2, - amp=1, + fixed_parameters={ + "angle_per_gate": np.pi, + "phase_offset": np.pi / 2, + } ) @classmethod @@ -222,8 +223,10 @@ def __init__( auto_update=auto_update, ) self.analysis.set_options( - angle_per_gate=np.pi / 2, - phase_offset=np.pi, + fixed_parameters={ + "angle_per_gate": np.pi / 2, + "phase_offset": np.pi, + } ) @classmethod diff --git a/qiskit_experiments/library/characterization/analysis/fine_amplitude_analysis.py b/qiskit_experiments/library/characterization/analysis/fine_amplitude_analysis.py index c256fdf0ee..d64dfd027e 100644 --- a/qiskit_experiments/library/characterization/analysis/fine_amplitude_analysis.py +++ b/qiskit_experiments/library/characterization/analysis/fine_amplitude_analysis.py @@ -58,5 +58,3 @@ class FineAmplitudeAnalysis(ErrorAmplificationAnalysis): filter_kwargs={"series": 1}, ), ] - - __fixed_parameters__ = ["angle_per_gate", "phase_offset"] diff --git a/qiskit_experiments/library/characterization/analysis/fine_drag_analysis.py b/qiskit_experiments/library/characterization/analysis/fine_drag_analysis.py index 4b23620959..5687ac47dd 100644 --- a/qiskit_experiments/library/characterization/analysis/fine_drag_analysis.py +++ b/qiskit_experiments/library/characterization/analysis/fine_drag_analysis.py @@ -12,6 +12,8 @@ """Fine DRAG calibration analysis.""" +import warnings + import numpy as np from qiskit_experiments.curve_analysis import ErrorAmplificationAnalysis from qiskit_experiments.framework import Options @@ -32,6 +34,16 @@ class FineDragAnalysis(ErrorAmplificationAnalysis): __fixed_parameters__ = ["angle_per_gate", "phase_offset", "amp"] + def __init__(self): + super().__init__() + + warnings.warn( + f"{self.__class__.__name__} has been deprecated. Use ErrorAmplificationAnalysis " + "instance with the analysis options involving the fixed_parameters.", + DeprecationWarning, + stacklevel=2, + ) + @classmethod def _default_options(cls) -> Options: """Default analysis options.""" diff --git a/qiskit_experiments/library/characterization/analysis/fine_frequency_analysis.py b/qiskit_experiments/library/characterization/analysis/fine_frequency_analysis.py index f816d882ae..1bb90a57b2 100644 --- a/qiskit_experiments/library/characterization/analysis/fine_frequency_analysis.py +++ b/qiskit_experiments/library/characterization/analysis/fine_frequency_analysis.py @@ -12,6 +12,8 @@ """Fine frequency experiment analysis.""" +import warnings + import numpy as np from qiskit_experiments.curve_analysis import ErrorAmplificationAnalysis @@ -33,6 +35,16 @@ class FineFrequencyAnalysis(ErrorAmplificationAnalysis): __fixed_parameters__ = ["angle_per_gate", "phase_offset"] + def __init__(self): + super().__init__() + + warnings.warn( + f"{self.__class__.__name__} has been deprecated. Use ErrorAmplificationAnalysis " + "instance with the analysis options involving the fixed_parameters.", + DeprecationWarning, + stacklevel=2, + ) + @classmethod def _default_options(cls) -> Options: """Default analysis options.""" diff --git a/qiskit_experiments/library/characterization/analysis/fine_half_angle_analysis.py b/qiskit_experiments/library/characterization/analysis/fine_half_angle_analysis.py index 2f57511731..94efc1237c 100644 --- a/qiskit_experiments/library/characterization/analysis/fine_half_angle_analysis.py +++ b/qiskit_experiments/library/characterization/analysis/fine_half_angle_analysis.py @@ -12,6 +12,8 @@ """Fine half angle calibration analysis.""" +import warnings + import numpy as np from qiskit_experiments.framework import Options from qiskit_experiments.curve_analysis import ErrorAmplificationAnalysis, ParameterRepr @@ -31,6 +33,16 @@ class FineHalfAngleAnalysis(ErrorAmplificationAnalysis): __fixed_parameters__ = ["angle_per_gate", "phase_offset", "amp"] + def __init__(self): + super().__init__() + + warnings.warn( + f"{self.__class__.__name__} has been deprecated. Use ErrorAmplificationAnalysis " + "instance with the analysis options involving the fixed_parameters.", + DeprecationWarning, + stacklevel=2, + ) + @classmethod def _default_options(cls) -> Options: r"""Default analysis options. diff --git a/qiskit_experiments/library/characterization/fine_amplitude.py b/qiskit_experiments/library/characterization/fine_amplitude.py index 429ce100b9..d3b7902217 100644 --- a/qiskit_experiments/library/characterization/fine_amplitude.py +++ b/qiskit_experiments/library/characterization/fine_amplitude.py @@ -254,9 +254,10 @@ def __init__(self, qubit: int, backend: Optional[Backend] = None): super().__init__([qubit], XGate(), backend=backend) # Set default analysis options self.analysis.set_options( - angle_per_gate=np.pi, - phase_offset=np.pi / 2, - amp=1, + fixed_parameters={ + "angle_per_gate": np.pi, + "phase_offset": np.pi / 2, + } ) @classmethod @@ -291,8 +292,10 @@ def __init__(self, qubit: int, backend: Optional[Backend] = None): super().__init__([qubit], SXGate(), backend=backend) # Set default analysis options self.analysis.set_options( - angle_per_gate=np.pi / 2, - phase_offset=np.pi, + fixed_parameters={ + "angle_per_gate": np.pi / 2, + "phase_offset": np.pi, + } ) @classmethod @@ -354,9 +357,10 @@ def __init__(self, qubits: Sequence[int], backend: Optional[Backend] = None): super().__init__(qubits, gate, backend=backend, measurement_qubits=[qubits[1]]) # Set default analysis options self.analysis.set_options( - angle_per_gate=np.pi / 2, - phase_offset=np.pi, - amp=1, + fixed_parameters={ + "angle_per_gate": np.pi / 2, + "phase_offset": np.pi, + }, outcome="1", ) diff --git a/qiskit_experiments/library/characterization/fine_drag.py b/qiskit_experiments/library/characterization/fine_drag.py index fa95a4ca41..485a33d4ea 100644 --- a/qiskit_experiments/library/characterization/fine_drag.py +++ b/qiskit_experiments/library/characterization/fine_drag.py @@ -20,9 +20,7 @@ from qiskit.circuit.library import XGate, SXGate from qiskit.providers.backend import Backend from qiskit_experiments.framework import BaseExperiment, Options -from qiskit_experiments.library.characterization.analysis import ( - FineDragAnalysis, -) +from qiskit_experiments.curve_analysis.standard_analysis import ErrorAmplificationAnalysis class FineDrag(BaseExperiment): @@ -126,7 +124,7 @@ class FineDrag(BaseExperiment): This is the correction formula in the FineDRAG Updater. # section: analysis_ref - :py:class:`FineDragAnalysis` + :py:class:`~qiskit_experiments.curve_analysis.ErrorAmplificationAnalysis` # section: see_also qiskit_experiments.library.calibration.drag.DragCal @@ -161,7 +159,17 @@ def __init__(self, qubit: int, gate: Gate, backend: Optional[Backend] = None): gate: The gate that will be repeated. backend: Optional, the backend to run the experiment on. """ - super().__init__([qubit], analysis=FineDragAnalysis(), backend=backend) + analysis = ErrorAmplificationAnalysis() + analysis.set_options( + normalization=True, + fixed_parameters={ + "angle_per_gate": 0.0, + "phase_offset": np.pi / 2, + "amp": 1.0, + }, + ) + + super().__init__([qubit], analysis=analysis, backend=backend) self.set_experiment_options(gate=gate) @staticmethod diff --git a/qiskit_experiments/library/characterization/fine_frequency.py b/qiskit_experiments/library/characterization/fine_frequency.py index fa9b63b36b..739e3edf48 100644 --- a/qiskit_experiments/library/characterization/fine_frequency.py +++ b/qiskit_experiments/library/characterization/fine_frequency.py @@ -19,7 +19,7 @@ from qiskit.providers.backend import Backend from qiskit_experiments.framework import BaseExperiment, Options -from qiskit_experiments.library.characterization.analysis import FineFrequencyAnalysis +from qiskit_experiments.curve_analysis.standard_analysis import ErrorAmplificationAnalysis class FineFrequency(BaseExperiment): @@ -47,7 +47,7 @@ class FineFrequency(BaseExperiment): meas: 1/══════════════════════════════════════════════╩═ 0 # section: analysis_ref - :py:class:`FineFrequencyAnalysis` + :py:class:`~qiskit_experiments.curve_analysis.ErrorAmplificationAnalysis` """ def __init__( @@ -66,7 +66,16 @@ def __init__( repetitions: The number of repetitions, if not given then the default value from the experiment default options will be used. """ - super().__init__([qubit], analysis=FineFrequencyAnalysis(), backend=backend) + analysis = ErrorAmplificationAnalysis() + analysis.set_options( + normalization=True, + fixed_parameters={ + "angle_per_gate": np.pi / 2, + "phase_offset": 0.0, + }, + ) + + super().__init__([qubit], analysis=analysis, backend=backend) if repetitions is not None: self.set_experiment_options(repetitions=repetitions) diff --git a/qiskit_experiments/library/characterization/half_angle.py b/qiskit_experiments/library/characterization/half_angle.py index c5c0c5b665..6b720cf4d8 100644 --- a/qiskit_experiments/library/characterization/half_angle.py +++ b/qiskit_experiments/library/characterization/half_angle.py @@ -19,7 +19,8 @@ from qiskit.providers import Backend from qiskit_experiments.framework import BaseExperiment, Options -from qiskit_experiments.library.characterization.analysis import FineHalfAngleAnalysis +from qiskit_experiments.curve_analysis.standard_analysis import ErrorAmplificationAnalysis +from qiskit_experiments.curve_analysis import ParameterRepr class HalfAngle(BaseExperiment): @@ -84,7 +85,23 @@ def __init__(self, qubit: int, backend: Optional[Backend] = None): qubit: The qubit on which to run the fine amplitude calibration experiment. backend: Optional, the backend to run the experiment on. """ - super().__init__([qubit], analysis=FineHalfAngleAnalysis(), backend=backend) + analysis = ErrorAmplificationAnalysis() + + default_bounds = analysis.options.bounds + default_bounds.update({"d_theta": (-np.pi / 2, np.pi / 2)}) + + analysis.set_options( + fixed_parameters={ + "angle_per_gate": np.pi, + "phase_offset": -np.pi / 2, + "amp": 1.0, + }, + result_parameters=[ParameterRepr("d_theta", "d_hac", "rad")], + normalization=True, + bounds=default_bounds, + ) + + super().__init__([qubit], analysis=analysis, backend=backend) @staticmethod def _pre_circuit() -> QuantumCircuit: diff --git a/releasenotes/notes/curve-analysis-fixed-parameters-5915a29db1e2628b.yaml b/releasenotes/notes/curve-analysis-fixed-parameters-5915a29db1e2628b.yaml new file mode 100644 index 0000000000..f0701c689d --- /dev/null +++ b/releasenotes/notes/curve-analysis-fixed-parameters-5915a29db1e2628b.yaml @@ -0,0 +1,23 @@ +--- +upgrade: + - | + New default :class:`CurveAnalysis` analysis option ``fixed_parameters`` + has been added. We can directly exclude parameters from the fit model + of the particular analysis instance, rather than defining a new class to define + the class attribute :attr:`CurveAnalysis.__fixed_parameters__`. +deprecations: + - | + Class attribute :attr:`CurveAnalysis.__fixed_parameters__` has been deprecated + and support for the instantiation of the class with this attribute will be dropped soon. + In addition, the fixed parameter value defined as a standalone analysis option + has been deprecated. Please set `fixed_parameters` option instead. + This is a python dictionary of fixed parameter values keyed on the fit parameter names. + - | + Analysis class :class:`FineDragAnalysis` has been deprecated. Now you can directly + set fixed parameters to the :class:`ErrorAmplificationAnalysis` instance as an analysis option. + - | + Analysis class :class:`FineFrequencyAnalysis` has been deprecated. Now you can directly + set fixed parameters to the :class:`ErrorAmplificationAnalysis` instance as an analysis option. + - | + Analysis class :class:`FineHalfAngleAnalysis` has been deprecated. Now you can directly + set fixed parameters to the :class:`ErrorAmplificationAnalysis` instance as an analysis option. diff --git a/test/calibration/experiments/test_fine_amplitude.py b/test/calibration/experiments/test_fine_amplitude.py index 5c25c1c93d..9d62667bde 100644 --- a/test/calibration/experiments/test_fine_amplitude.py +++ b/test/calibration/experiments/test_fine_amplitude.py @@ -158,8 +158,10 @@ def test_fine_x_amp(self): exp = FineXAmplitude(0) self.assertTrue(exp.experiment_options.add_cal_circuits) - self.assertEqual(exp.analysis.options.angle_per_gate, np.pi) - self.assertEqual(exp.analysis.options.phase_offset, np.pi / 2) + self.assertDictEqual( + exp.analysis.options.fixed_parameters, + {"angle_per_gate": np.pi, "phase_offset": np.pi / 2}, + ) self.assertEqual(exp.experiment_options.gate, XGate()) def test_fine_sx_amp(self): @@ -171,8 +173,10 @@ def test_fine_sx_amp(self): expected = [0, 1, 2, 3, 5, 7, 9, 11, 13, 15, 17, 21, 23, 25] self.assertEqual(exp.experiment_options.repetitions, expected) - self.assertEqual(exp.analysis.options.angle_per_gate, np.pi / 2) - self.assertEqual(exp.analysis.options.phase_offset, np.pi) + self.assertDictEqual( + exp.analysis.options.fixed_parameters, + {"angle_per_gate": np.pi / 2, "phase_offset": np.pi}, + ) self.assertEqual(exp.experiment_options.gate, SXGate()) @data((2, 3), (3, 1), (0, 1)) diff --git a/test/curve_analysis/test_curve_fit.py b/test/curve_analysis/test_curve_fit.py index 2dddda850e..ef5051b206 100644 --- a/test/curve_analysis/test_curve_fit.py +++ b/test/curve_analysis/test_curve_fit.py @@ -64,7 +64,14 @@ class TestAnalysis(CurveAnalysis): """A mock analysis class to test.""" __series__ = series - __fixed_parameters__ = fixed_params or list() + + @classmethod + def _default_options(cls): + opts = super()._default_options() + if fixed_params: + opts.fixed_parameters = {p: None for p in fixed_params} + + return opts return TestAnalysis() @@ -117,24 +124,24 @@ def setUp(self): series=[ SeriesDef( name="curve1", - fit_func=lambda x, p0, p1, p2, p3, p4: fit_function.exponential_decay( - x, amp=p0, lamb=p1, baseline=p4 + fit_func=lambda x, par0, par1, par2, par3, par4: fit_function.exponential_decay( + x, amp=par0, lamb=par1, baseline=par4 ), filter_kwargs={"type": 1, "valid": True}, model_description=r"p_0 * \exp(p_1 x) + p4", ), SeriesDef( name="curve2", - fit_func=lambda x, p0, p1, p2, p3, p4: fit_function.exponential_decay( - x, amp=p0, lamb=p2, baseline=p4 + fit_func=lambda x, par0, par1, par2, par3, par4: fit_function.exponential_decay( + x, amp=par0, lamb=par2, baseline=par4 ), filter_kwargs={"type": 2, "valid": True}, model_description=r"p_0 * \exp(p_2 x) + p4", ), SeriesDef( name="curve3", - fit_func=lambda x, p0, p1, p2, p3, p4: fit_function.exponential_decay( - x, amp=p0, lamb=p3, baseline=p4 + fit_func=lambda x, par0, par1, par2, par3, par4: fit_function.exponential_decay( + x, amp=par0, lamb=par3, baseline=par4 ), filter_kwargs={"type": 3, "valid": True}, model_description=r"p_0 * \exp(p_3 x) + p4", @@ -145,43 +152,27 @@ def setUp(self): def test_parsed_fit_params(self): """Test parsed fit params.""" - self.assertSetEqual(set(self.analysis._fit_params()), {"p0", "p1", "p2", "p3", "p4"}) - - def test_parsed_init_guess(self): - """Test parsed initial guess and boundaries.""" - default_p0 = self.analysis._default_options().p0 - default_bounds = self.analysis._default_options().bounds - ref = {"p0": None, "p1": None, "p2": None, "p3": None, "p4": None} - self.assertDictEqual(default_p0, ref) - self.assertDictEqual(default_bounds, ref) + self.assertSetEqual( + set(self.analysis._fit_params()), {"par0", "par1", "par2", "par3", "par4"} + ) def test_cannot_create_invalid_series_fit(self): """Test we cannot create invalid analysis instance.""" invalid_series = [ SeriesDef( name="fit1", - fit_func=lambda x, p0: fit_function.exponential_decay(x, amp=p0), + fit_func=lambda x, par0: fit_function.exponential_decay(x, amp=par0), ), SeriesDef( name="fit2", - fit_func=lambda x, p1: fit_function.exponential_decay(x, amp=p1), + fit_func=lambda x, par1: fit_function.exponential_decay(x, amp=par1), ), ] - with self.assertRaises(AnalysisError): - create_new_analysis(series=invalid_series) # fit1 has param p0 while fit2 has p1 - def test_cannot_create_invalid_fixed_parameter(self): - """Test we cannot create invalid analysis instance with wrong fixed value name.""" - valid_series = [ - SeriesDef( - fit_func=lambda x, p0, p1: fit_function.exponential_decay(x, amp=p0, lamb=p1), - ), - ] + instance = create_new_analysis(series=invalid_series) with self.assertRaises(AnalysisError): - create_new_analysis( - series=valid_series, - fixed_params=["not_existing_parameter"], # this parameter is not defined - ) + # pylint: disable=pointless-statement + instance.parameters # fit1 has param par0 while fit2 has par1 def test_data_extraction(self): """Test data extraction method.""" @@ -295,8 +286,8 @@ def test_run_single_curve_analysis(self): series=[ SeriesDef( name="curve1", - fit_func=lambda x, p0, p1, p2, p3: fit_function.exponential_decay( - x, amp=p0, lamb=p1, x0=p2, baseline=p3 + fit_func=lambda x, par0, par1, par2, par3: fit_function.exponential_decay( + x, amp=par0, lamb=par1, x0=par2, baseline=par3 ), model_description=r"p_0 \exp(p_1 x + p_2) + p_3", ) @@ -313,8 +304,8 @@ def test_run_single_curve_analysis(self): param_dict={"amp": ref_p0, "lamb": ref_p1, "x0": ref_p2, "baseline": ref_p3}, ) analysis.set_options( - p0={"p0": ref_p0, "p1": ref_p1, "p2": ref_p2, "p3": ref_p3}, - result_parameters=[ParameterRepr("p1", "parameter_name", "unit")], + p0={"par0": ref_p0, "par1": ref_p1, "par2": ref_p2, "par3": ref_p3}, + result_parameters=[ParameterRepr("par1", "parameter_name", "unit")], ) results, _ = analysis._run_analysis(test_data) @@ -325,7 +316,7 @@ def test_run_single_curve_analysis(self): # check result data np.testing.assert_array_almost_equal(result.value, ref_popt, decimal=self.err_decimal) self.assertEqual(result.extra["dof"], 46) - self.assertListEqual(result.extra["popt_keys"], ["p0", "p1", "p2", "p3"]) + self.assertListEqual(result.extra["popt_keys"], ["par0", "par1", "par2", "par3"]) self.assertDictEqual(result.extra["fit_models"], {"curve1": r"p_0 \exp(p_1 x + p_2) + p_3"}) # special entry formatted for database @@ -340,8 +331,8 @@ def test_run_single_curve_fail(self): series=[ SeriesDef( name="curve1", - fit_func=lambda x, p0, p1, p2, p3: fit_function.exponential_decay( - x, amp=p0, lamb=p1, x0=p2, baseline=p3 + fit_func=lambda x, par0, par1, par2, par3: fit_function.exponential_decay( + x, amp=par0, lamb=par1, x0=par2, baseline=par3 ), ) ], @@ -357,8 +348,8 @@ def test_run_single_curve_fail(self): param_dict={"amp": ref_p0, "lamb": ref_p1, "x0": ref_p2, "baseline": ref_p3}, ) analysis.set_options( - p0={"p0": ref_p0, "p1": ref_p1, "p2": ref_p2, "p3": ref_p3}, - bounds={"p0": [-10, 0], "p1": [-10, 0], "p2": [-10, 0], "p3": [-10, 0]}, + p0={"par0": ref_p0, "par1": ref_p1, "par2": ref_p2, "par3": ref_p3}, + bounds={"par0": [-10, 0], "par1": [-10, 0], "par2": [-10, 0], "par3": [-10, 0]}, return_data_points=True, ) @@ -375,15 +366,15 @@ def test_run_two_curves_with_same_fitfunc(self): series=[ SeriesDef( name="curve1", - fit_func=lambda x, p0, p1, p2, p3, p4: fit_function.exponential_decay( - x, amp=p0, lamb=p1, x0=p3, baseline=p4 + fit_func=lambda x, par0, par1, par2, par3, par4: fit_function.exponential_decay( + x, amp=par0, lamb=par1, x0=par3, baseline=par4 ), filter_kwargs={"exp": 0}, ), SeriesDef( name="curve1", - fit_func=lambda x, p0, p1, p2, p3, p4: fit_function.exponential_decay( - x, amp=p0, lamb=p2, x0=p3, baseline=p4 + fit_func=lambda x, par0, par1, par2, par3, par4: fit_function.exponential_decay( + x, amp=par0, lamb=par2, x0=par3, baseline=par4 ), filter_kwargs={"exp": 1}, ), @@ -414,7 +405,7 @@ def test_run_two_curves_with_same_fitfunc(self): test_data0.add_data(datum) analysis.set_options( - p0={"p0": ref_p0, "p1": ref_p1, "p2": ref_p2, "p3": ref_p3, "p4": ref_p4} + p0={"par0": ref_p0, "par1": ref_p1, "par2": ref_p2, "par3": ref_p3, "par4": ref_p4} ) results, _ = analysis._run_analysis(test_data0) result = results[0] @@ -430,15 +421,15 @@ def test_run_two_curves_with_two_fitfuncs(self): series=[ SeriesDef( name="curve1", - fit_func=lambda x, p0, p1, p2, p3: fit_function.cos( - x, amp=p0, freq=p1, phase=p2, baseline=p3 + fit_func=lambda x, par0, par1, par2, par3: fit_function.cos( + x, amp=par0, freq=par1, phase=par2, baseline=par3 ), filter_kwargs={"exp": 0}, ), SeriesDef( name="curve2", - fit_func=lambda x, p0, p1, p2, p3: fit_function.sin( - x, amp=p0, freq=p1, phase=p2, baseline=p3 + fit_func=lambda x, par0, par1, par2, par3: fit_function.sin( + x, amp=par0, freq=par1, phase=par2, baseline=par3 ), filter_kwargs={"exp": 1}, ), @@ -467,7 +458,7 @@ def test_run_two_curves_with_two_fitfuncs(self): for datum in test_data1.data(): test_data0.add_data(datum) - analysis.set_options(p0={"p0": ref_p0, "p1": ref_p1, "p2": ref_p2, "p3": ref_p3}) + analysis.set_options(p0={"par0": ref_p0, "par1": ref_p1, "par2": ref_p2, "par3": ref_p3}) results, _ = analysis._run_analysis(test_data0) result = results[0] @@ -482,12 +473,12 @@ def test_run_fixed_parameters(self): series=[ SeriesDef( name="curve1", - fit_func=lambda x, p0, p1, fixed_p2, p3: fit_function.cos( - x, amp=p0, freq=p1, phase=fixed_p2, baseline=p3 + fit_func=lambda x, par0, par1, fixed_par2, par3: fit_function.cos( + x, amp=par0, freq=par1, phase=fixed_par2, baseline=par3 ), ), ], - fixed_params=["fixed_p2"], + fixed_params=["fixed_par2"], ) ref_p0 = 0.1 @@ -502,8 +493,8 @@ def test_run_fixed_parameters(self): ) analysis.set_options( - p0={"p0": ref_p0, "p1": ref_p1, "p3": ref_p3}, - fixed_p2=ref_p2, + p0={"par0": ref_p0, "par1": ref_p1, "par3": ref_p3}, + fixed_parameters={"fixed_par2": ref_p2}, ) results, _ = analysis._run_analysis(test_data) @@ -520,8 +511,8 @@ def test_fixed_param_is_missing(self): series=[ SeriesDef( name="curve1", - fit_func=lambda x, p0, p1, fixed_p2, p3: fit_function.cos( - x, amp=p0, freq=p1, phase=fixed_p2, baseline=p3 + fit_func=lambda x, par0, par1, fixed_par2, par3: fit_function.cos( + x, amp=par0, freq=par1, phase=fixed_par2, baseline=par3 ), ), ], @@ -539,7 +530,7 @@ def test_fixed_param_is_missing(self): param_dict={"amp": ref_p0, "freq": ref_p1, "phase": ref_p2, "baseline": ref_p3}, ) # do not define fixed_p2 here - analysis.set_options(p0={"p0": ref_p0, "p1": ref_p1, "p3": ref_p3}) + analysis.set_options(p0={"par0": ref_p0, "par1": ref_p1, "par3": ref_p3}) with self.assertRaises(AnalysisError): analysis._run_analysis(test_data) @@ -549,12 +540,16 @@ class TestFitOptions(QiskitExperimentsTestCase): def test_empty(self): """Test if default value is automatically filled.""" - opt = FitOptions(["p0", "p1", "p2"]) + opt = FitOptions(["par0", "par1", "par2"]) # bounds should be default to inf tuple. otherwise crashes the scipy fitter. ref_opts = { - "p0": {"p0": None, "p1": None, "p2": None}, - "bounds": {"p0": (-np.inf, np.inf), "p1": (-np.inf, np.inf), "p2": (-np.inf, np.inf)}, + "p0": {"par0": None, "par1": None, "par2": None}, + "bounds": { + "par0": (-np.inf, np.inf), + "par1": (-np.inf, np.inf), + "par2": (-np.inf, np.inf), + }, } self.assertDictEqual(opt.options, ref_opts) @@ -562,14 +557,14 @@ def test_empty(self): def test_create_option_with_dict(self): """Create option and fill with dictionary.""" opt = FitOptions( - ["p0", "p1", "p2"], - default_p0={"p0": 0, "p1": 1, "p2": 2}, - default_bounds={"p0": (0, 1), "p1": (1, 2), "p2": (2, 3)}, + ["par0", "par1", "par2"], + default_p0={"par0": 0, "par1": 1, "par2": 2}, + default_bounds={"par0": (0, 1), "par1": (1, 2), "par2": (2, 3)}, ) ref_opts = { - "p0": {"p0": 0.0, "p1": 1.0, "p2": 2.0}, - "bounds": {"p0": (0.0, 1.0), "p1": (1.0, 2.0), "p2": (2.0, 3.0)}, + "p0": {"par0": 0.0, "par1": 1.0, "par2": 2.0}, + "bounds": {"par0": (0.0, 1.0), "par1": (1.0, 2.0), "par2": (2.0, 3.0)}, } self.assertDictEqual(opt.options, ref_opts) @@ -577,73 +572,89 @@ def test_create_option_with_dict(self): def test_create_option_with_array(self): """Create option and fill with array.""" opt = FitOptions( - ["p0", "p1", "p2"], + ["par0", "par1", "par2"], default_p0=[0, 1, 2], default_bounds=[(0, 1), (1, 2), (2, 3)], ) ref_opts = { - "p0": {"p0": 0.0, "p1": 1.0, "p2": 2.0}, - "bounds": {"p0": (0.0, 1.0), "p1": (1.0, 2.0), "p2": (2.0, 3.0)}, + "p0": {"par0": 0.0, "par1": 1.0, "par2": 2.0}, + "bounds": {"par0": (0.0, 1.0), "par1": (1.0, 2.0), "par2": (2.0, 3.0)}, } self.assertDictEqual(opt.options, ref_opts) def test_override_partial_dict(self): """Create option and override value with partial dictionary.""" - opt = FitOptions(["p0", "p1", "p2"]) - opt.p0.set_if_empty(p1=3) + opt = FitOptions(["par0", "par1", "par2"]) + opt.p0.set_if_empty(par1=3) ref_opts = { - "p0": {"p0": None, "p1": 3.0, "p2": None}, - "bounds": {"p0": (-np.inf, np.inf), "p1": (-np.inf, np.inf), "p2": (-np.inf, np.inf)}, + "p0": {"par0": None, "par1": 3.0, "par2": None}, + "bounds": { + "par0": (-np.inf, np.inf), + "par1": (-np.inf, np.inf), + "par2": (-np.inf, np.inf), + }, } self.assertDictEqual(opt.options, ref_opts) def test_cannot_override_assigned_value(self): """Test cannot override already assigned value.""" - opt = FitOptions(["p0", "p1", "p2"]) - opt.p0.set_if_empty(p1=3) - opt.p0.set_if_empty(p1=5) + opt = FitOptions(["par0", "par1", "par2"]) + opt.p0.set_if_empty(par1=3) + opt.p0.set_if_empty(par1=5) ref_opts = { - "p0": {"p0": None, "p1": 3.0, "p2": None}, - "bounds": {"p0": (-np.inf, np.inf), "p1": (-np.inf, np.inf), "p2": (-np.inf, np.inf)}, + "p0": {"par0": None, "par1": 3.0, "par2": None}, + "bounds": { + "par0": (-np.inf, np.inf), + "par1": (-np.inf, np.inf), + "par2": (-np.inf, np.inf), + }, } self.assertDictEqual(opt.options, ref_opts) def test_can_override_assigned_value_with_dict_access(self): """Test override already assigned value with direct dict access.""" - opt = FitOptions(["p0", "p1", "p2"]) - opt.p0["p1"] = 3 - opt.p0["p1"] = 5 + opt = FitOptions(["par0", "par1", "par2"]) + opt.p0["par1"] = 3 + opt.p0["par1"] = 5 ref_opts = { - "p0": {"p0": None, "p1": 5.0, "p2": None}, - "bounds": {"p0": (-np.inf, np.inf), "p1": (-np.inf, np.inf), "p2": (-np.inf, np.inf)}, + "p0": {"par0": None, "par1": 5.0, "par2": None}, + "bounds": { + "par0": (-np.inf, np.inf), + "par1": (-np.inf, np.inf), + "par2": (-np.inf, np.inf), + }, } self.assertDictEqual(opt.options, ref_opts) def test_cannot_override_user_option(self): """Test cannot override already assigned value.""" - opt = FitOptions(["p0", "p1", "p2"], default_p0={"p1": 3}) - opt.p0.set_if_empty(p1=5) + opt = FitOptions(["par0", "par1", "par2"], default_p0={"par1": 3}) + opt.p0.set_if_empty(par1=5) ref_opts = { - "p0": {"p0": None, "p1": 3, "p2": None}, - "bounds": {"p0": (-np.inf, np.inf), "p1": (-np.inf, np.inf), "p2": (-np.inf, np.inf)}, + "p0": {"par0": None, "par1": 3, "par2": None}, + "bounds": { + "par0": (-np.inf, np.inf), + "par1": (-np.inf, np.inf), + "par2": (-np.inf, np.inf), + }, } self.assertDictEqual(opt.options, ref_opts) def test_set_operation(self): """Test if set works and duplicated entry is removed.""" - opt1 = FitOptions(["p0", "p1"], default_p0=[0, 1]) - opt2 = FitOptions(["p0", "p1"], default_p0=[0, 1]) - opt3 = FitOptions(["p0", "p1"], default_p0=[0, 2]) + opt1 = FitOptions(["par0", "par1"], default_p0=[0, 1]) + opt2 = FitOptions(["par0", "par1"], default_p0=[0, 1]) + opt3 = FitOptions(["par0", "par1"], default_p0=[0, 2]) opts = set() opts.add(opt1) @@ -656,39 +667,39 @@ def test_detect_invalid_p0(self): """Test if invalid p0 raises Error.""" with self.assertRaises(AnalysisError): # less element - FitOptions(["p0", "p1", "p2"], default_p0=[0, 1]) + FitOptions(["par0", "par1", "par2"], default_p0=[0, 1]) def test_detect_invalid_bounds(self): """Test if invalid bounds raises Error.""" with self.assertRaises(AnalysisError): # less element - FitOptions(["p0", "p1", "p2"], default_bounds=[(0, 1), (1, 2)]) + FitOptions(["par0", "par1", "par2"], default_bounds=[(0, 1), (1, 2)]) with self.assertRaises(AnalysisError): # not min-max tuple - FitOptions(["p0", "p1", "p2"], default_bounds=[0, 1, 2]) + FitOptions(["par0", "par1", "par2"], default_bounds=[0, 1, 2]) with self.assertRaises(AnalysisError): # max-min tuple - FitOptions(["p0", "p1", "p2"], default_bounds=[(1, 0), (2, 1), (3, 2)]) + FitOptions(["par0", "par1", "par2"], default_bounds=[(1, 0), (2, 1), (3, 2)]) def test_detect_invalid_key(self): """Test if invalid key raises Error.""" - opt = FitOptions(["p0", "p1", "p2"]) + opt = FitOptions(["par0", "par1", "par2"]) with self.assertRaises(AnalysisError): - opt.p0.set_if_empty(p3=3) + opt.p0.set_if_empty(par3=3) def test_set_extra_options(self): """Add extra fitter options.""" opt = FitOptions( - ["p0", "p1", "p2"], default_p0=[0, 1, 2], default_bounds=[(0, 1), (1, 2), (2, 3)] + ["par0", "par1", "par2"], default_p0=[0, 1, 2], default_bounds=[(0, 1), (1, 2), (2, 3)] ) opt.add_extra_options(ex1=0, ex2=1) ref_opts = { - "p0": {"p0": 0.0, "p1": 1.0, "p2": 2.0}, - "bounds": {"p0": (0.0, 1.0), "p1": (1.0, 2.0), "p2": (2.0, 3.0)}, + "p0": {"par0": 0.0, "par1": 1.0, "par2": 2.0}, + "bounds": {"par0": (0.0, 1.0), "par1": (1.0, 2.0), "par2": (2.0, 3.0)}, "ex1": 0, "ex2": 1, } @@ -697,40 +708,89 @@ def test_set_extra_options(self): def test_complicated(self): """Test for realistic operations for algorithmic guess with user options.""" - user_p0 = {"p0": 1, "p1": None} - user_bounds = {"p0": None, "p1": (-100, 100)} + user_p0 = {"par0": 1, "par1": None} + user_bounds = {"par0": None, "par1": (-100, 100)} opt = FitOptions( - ["p0", "p1", "p2"], + ["par0", "par1", "par2"], default_p0=user_p0, default_bounds=user_bounds, ) # similar computation in algorithmic guess - opt.p0.set_if_empty(p0=5) # this is ignored because user already provided initial guess - opt.p0.set_if_empty(p1=opt.p0["p0"] * 2 + 3) # user provided guess propagates + opt.p0.set_if_empty(par0=5) # this is ignored because user already provided initial guess + opt.p0.set_if_empty(par1=opt.p0["par0"] * 2 + 3) # user provided guess propagates - opt.bounds.set_if_empty(p0=(0, 10)) # this will be set + opt.bounds.set_if_empty(par0=(0, 10)) # this will be set opt.add_extra_options(fitter="algo1") opt1 = opt.copy() # copy options while keeping previous values - opt1.p0.set_if_empty(p2=opt1.p0["p0"] + opt1.p0["p1"]) + opt1.p0.set_if_empty(par2=opt1.p0["par0"] + opt1.p0["par1"]) opt2 = opt.copy() - opt2.p0.set_if_empty(p2=opt2.p0["p0"] * 2) # add another p2 value + opt2.p0.set_if_empty(par2=opt2.p0["par0"] * 2) # add another p2 value ref_opt1 = { - "p0": {"p0": 1.0, "p1": 5.0, "p2": 6.0}, - "bounds": {"p0": (0.0, 10.0), "p1": (-100.0, 100.0), "p2": (-np.inf, np.inf)}, + "p0": {"par0": 1.0, "par1": 5.0, "par2": 6.0}, + "bounds": {"par0": (0.0, 10.0), "par1": (-100.0, 100.0), "par2": (-np.inf, np.inf)}, "fitter": "algo1", } ref_opt2 = { - "p0": {"p0": 1.0, "p1": 5.0, "p2": 2.0}, - "bounds": {"p0": (0.0, 10.0), "p1": (-100.0, 100.0), "p2": (-np.inf, np.inf)}, + "p0": {"par0": 1.0, "par1": 5.0, "par2": 2.0}, + "bounds": {"par0": (0.0, 10.0), "par1": (-100.0, 100.0), "par2": (-np.inf, np.inf)}, "fitter": "algo1", } self.assertDictEqual(opt1.options, ref_opt1) self.assertDictEqual(opt2.options, ref_opt2) + + +class TestBackwardCompatibility(QiskitExperimentsTestCase): + """Test case for backward compatibility.""" + + def test_old_fixed_param_attributes(self): + """Test if old class structure for fixed param is still supported.""" + + class _DeprecatedAnalysis(CurveAnalysis): + __series__ = [ + SeriesDef( + fit_func=lambda x, par0, par1, par2, par3: fit_function.exponential_decay( + x, amp=par0, lamb=par1, x0=par2, baseline=par3 + ), + ) + ] + + __fixed_parameters__ = ["par1"] + + @classmethod + def _default_options(cls): + opts = super()._default_options() + opts.par1 = 2 + + return opts + + with self.assertWarns(DeprecationWarning): + instance = _DeprecatedAnalysis() + + self.assertDictEqual(instance.options.fixed_parameters, {"par1": 2}) + + def test_loading_data_with_deprecated_fixed_param(self): + """Test loading old data with fixed parameters as standalone options.""" + + class _DeprecatedAnalysis(CurveAnalysis): + __series__ = [ + SeriesDef( + fit_func=lambda x, par0, par1, par2, par3: fit_function.exponential_decay( + x, amp=par0, lamb=par1, x0=par2, baseline=par3 + ), + ) + ] + + with self.assertWarns(DeprecationWarning): + # old option data structure, i.e. fixed param as a standalone option + # the analysis instance fixed parameters might be set via the experiment instance + instance = _DeprecatedAnalysis.from_config({"options": {"par1": 2}}) + + self.assertDictEqual(instance.options.fixed_parameters, {"par1": 2}) diff --git a/test/curve_analysis/test_standard_analysis.py b/test/curve_analysis/test_standard_analysis.py index c54f115b78..b12c543e38 100644 --- a/test/curve_analysis/test_standard_analysis.py +++ b/test/curve_analysis/test_standard_analysis.py @@ -50,15 +50,15 @@ def test_fit_vals(self, d_theta_targ): class FakeAmpAnalysis(ErrorAmplificationAnalysis): """Analysis class for testing.""" - __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.fixed_parameters = { + "angle_per_gate": np.pi, + "phase_offset": np.pi / 2, + "amp": 1.0, + } return options