Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
5db1a42
Add CurveFitAnalysis class
chriseclectic Mar 17, 2021
a6d9c93
Get outcome label from metadata
chriseclectic Mar 17, 2021
b816312
Fix import error message if no matplotlib
chriseclectic Mar 17, 2021
1e0e755
Add option for fitting means of batched data sets
chriseclectic Mar 17, 2021
8d1c971
Reorganize helper functions into separate file
chriseclectic Mar 19, 2021
4c8057c
Add curve_fit, multi_curve_fit, and plotting functions for xy data
chriseclectic Mar 26, 2021
271995a
Refactor example CurveFitAnalysis class to use library functions
chriseclectic Mar 19, 2021
0a7fb8b
Add curve fitting data
chriseclectic Apr 8, 2021
0ba6a74
Remove CurveFitAnalysis class and leave as library functions
chriseclectic Apr 8, 2021
ee870b9
Linting
chriseclectic Apr 8, 2021
f54cfef
Clean up curve_fit_data
chriseclectic Apr 8, 2021
5457af8
Run black
chriseclectic Apr 8, 2021
c084e67
Add filters to curve fit data
chriseclectic Apr 8, 2021
e2998f9
Reorganize files
chriseclectic Apr 8, 2021
c19e55c
Remove unneeded init import
chriseclectic Apr 8, 2021
1992900
Initial review edits
chriseclectic Apr 9, 2021
a1acfac
Separate x and series data for multi curve fit
chriseclectic Apr 9, 2021
e091964
Allow passing p0, and bounds as dict to curve fit
chriseclectic Apr 9, 2021
6bb4dc7
Add sigma to mean_xy_data
chriseclectic Apr 9, 2021
361ae6c
Set default absolute_sigma = True
chriseclectic Apr 12, 2021
ab805e9
Fix logical check on np array
chriseclectic Apr 12, 2021
17876df
Use reduced chi-squared calculation
chriseclectic Apr 12, 2021
90f1926
fixup data processing
chriseclectic Apr 13, 2021
363d09e
Add basic tests for curve fitting
chriseclectic Apr 13, 2021
9024ded
More review edits
chriseclectic Apr 14, 2021
6b1f362
Merge branch 'main' into curve-fit-analysis
Apr 19, 2021
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions qiskit_experiments/analysis/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# This code is part of Qiskit.
#
# (C) Copyright IBM 2021.
#
# This code is licensed under the Apache License, Version 2.0. You may
# obtain a copy of this license in the LICENSE.txt file in the root directory
# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
#
# Any modifications or derivative works of this code must retain this
# copyright notice, and modified files need to carry a notice indicating
# that they have been altered from the originals.

"""
Analysis helper functions
"""
304 changes: 304 additions & 0 deletions qiskit_experiments/analysis/curve_fitting.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,304 @@
# This code is part of Qiskit.
#
# (C) Copyright IBM 2021.
#
# This code is licensed under the Apache License, Version 2.0. You may
# obtain a copy of this license in the LICENSE.txt file in the root directory
# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
#
# Any modifications or derivative works of this code must retain this
# copyright notice, and modified files need to carry a notice indicating
# that they have been altered from the originals.
"""
Curve fitting functions for experiment analysis
"""
# pylint: disable = invalid-name

from typing import List, Dict, Tuple, Callable, Optional, Union

import numpy as np
import scipy.optimize as opt
from qiskit.exceptions import QiskitError
from qiskit_experiments.base_analysis import AnalysisResult
from qiskit_experiments.analysis.data_processing import filter_data


def curve_fit(
func: Callable,
xdata: np.ndarray,
ydata: np.ndarray,
p0: Union[Dict[str, float], np.ndarray],
sigma: Optional[np.ndarray] = None,
bounds: Optional[Union[Dict[str, Tuple[float, float]], Tuple[np.ndarray, np.ndarray]]] = None,
**kwargs,
) -> AnalysisResult:
r"""Perform a non-linear least squares to fit

This solves the optimization problem

.. math::
\Theta_{\mbox{opt}} = \arg\min_\Theta \sum_i
\sigma_i^{-2} (f(x_i, \Theta) - y_i)^2

using ``scipy.optimize.curve_fit``.

Args:
func: a fit function `f(x, *params)`.
xdata: a 1D float array of x-data.
ydata: a 1D float array of y-data.
p0: initial guess for optimization parameters.
sigma: Optional, a 1D array of standard deviations in ydata
in absolute units.
bounds: Optional, lower and upper bounds for optimization
parameters.
kwargs: additional kwargs for scipy.optimize.curve_fit.

Returns:
result containing ``popt`` the optimal fit parameters,
``popt_err`` the standard error estimates popt,
``pcov`` the covariance matrix for the fit,
``reduced_chisq`` the reduced chi-squared parameter of fit,
``dof`` the degrees of freedom of the fit,
``xrange`` the range of xdata values used for fit.

Raises:
QiskitError: if the number of degrees of freedom of the fit is
less than 1.

.. note::
``sigma`` is assumed to be specified in the same units as ``ydata``
(absolute units). If sigma is instead specified in relative units
the `absolute_sigma=False` kwarg of scipy curve_fit must be used.
This affects the returned covariance ``pcov`` and error ``popt_err``
parameters via ``pcov(absolute_sigma=False) = pcov * reduced_chisq``
``popt_err(absolute_sigma=False) = popt_err * sqrt(reduced_chisq)``.
"""
# Format p0 parameters if specified as dictionary
if isinstance(p0, dict):
param_keys = list(p0.keys())

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this will make the ordering of p0 as a vector unstable, which will probably mess someone up when they assume the covariance matrix is always ordered the same. Maybe sort alphabetically by key or something?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently the popt, popt_keys, popt_err, pcov ordering will be the ordering of dict.keys. The popt_keys is intended to remove any ambiguity for which params these are. I don't think it makes that much more sense to order alphabetically instead of the order the dict was originally constructed if p0 is a dict.

param_p0 = list(p0.values())

# Convert bounds
if bounds:
lower = [bounds[key][0] for key in param_keys]
upper = [bounds[key][1] for key in param_keys]
param_bounds = (lower, upper)
else:
param_bounds = None

# Convert fit function
def fit_func(x, *params):
return func(x, **dict(zip(param_keys, params)))

else:
param_keys = None
param_p0 = p0
param_bounds = bounds
fit_func = func

# Check the degrees of freedom is greater than 0
dof = len(ydata) - len(param_p0)
if dof < 1:
raise QiskitError(
"The number of degrees of freedom of the fit data and model "
" (len(ydata) - len(p0)) is less than 1"
)

# Override scipy.curve_fit default for absolute_sigma=True
# if sigma is specified.
if sigma is not None and "absolute_sigma" not in kwargs:
kwargs["absolute_sigma"] = True

# Run curve fit
# TODO: Add error handling so if fitting fails we can return an analysis
# result containing this information
# pylint: disable = unbalanced-tuple-unpacking
popt, pcov = opt.curve_fit(
fit_func, xdata, ydata, sigma=sigma, p0=param_p0, bounds=param_bounds, **kwargs
)
popt_err = np.sqrt(np.diag(pcov))

# Calculate the reduced chi-squared for fit
yfits = fit_func(xdata, *popt)
residues = (yfits - ydata) ** 2
if sigma is not None:
residues = residues / (sigma ** 2)
reduced_chisq = np.sum(residues) / dof

# Compute xdata range for fit
xdata_range = [min(xdata), max(xdata)]
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we optionally truncate the fit range? i.e. adding range truncation function in the helper library. This is useful for fitting power dependency (sometime we see non-linearly with large xvalue, and want to exclude this region)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I could add something like a xbounds=[xmin, xmax] kwarg to the curve_fit_data function.

Or maybe a more general solution would be to beef up the filters so they aren't just value checks meta_val == filter_val, but instead callables filter_data(data, meta_key=fn) where the fn is callable that evaluates to a bool fn(meta_val) -> bool.

Im not sure how you could allow both callable and fixed value filters though. They might just have to be all callable if we do that (like a fixed value one would need to be done something like filter_data(data, meta_key=(lambda x: x == target))

Copy link
Collaborator

@nkanazawa1989 nkanazawa1989 Apr 9, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think **filter is used to filter out circuit based on metadata so current implementation looks good. xbounds kwarg looks good to me. I also noticed the function lacks bounds for parameter (if this curve_fit is scipy wrapper we should have bounds argument).

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any of scipy curve_fits other kwargs such as bounds are passed through via the **kwargs of curve_fit and multi_curve_fit

Copy link
Collaborator

@nkanazawa1989 nkanazawa1989 Apr 15, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah I just meant consistency of function signature, or docstring. current interface is ok in terms of functionality. thanks for updating.


result = {
"popt": popt,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question; if the user passed in a dict, should we return a dict here?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To avoid having two different return types, i left this as an array, and if p0 was supplied with a dict the popt_keys will include the corresponding key labels for each param

"popt_keys": param_keys,
"popt_err": popt_err,
"pcov": pcov,
"reduced_chisq": reduced_chisq,
"dof": dof,
"xrange": xdata_range,
}

return AnalysisResult(result)


def multi_curve_fit(
funcs: List[Callable],
series: np.ndarray,
xdata: np.ndarray,
ydata: np.ndarray,
p0: np.ndarray,
sigma: Optional[np.ndarray] = None,
weights: Optional[np.ndarray] = None,
bounds: Optional[Union[Dict[str, Tuple[float, float]], Tuple[np.ndarray, np.ndarray]]] = None,
**kwargs,
) -> AnalysisResult:
r"""Perform a linearized multi-objective non-linear least squares fit.

This solves the optimization problem

.. math::
\Theta_{\mbox{opt}} = \arg\min_\Theta \sum_{k} w_k
\sum_{i} \sigma_{k, i}^{-2}
(f_k(x_{k, i}, \Theta) - y_{k, i})^2

for multiple series of :math:`x_k, y_k, \sigma_k` data evaluated using
a list of objective functions :math:`[f_k]`
using ``scipy.optimize.curve_fit``.

Args:
funcs: a list of objective functions :math:`[f_0, f_1, ...]` where
each function has signature :math`f_k`(x, *params)`.
series: a 1D int array that specifies the component objective
function :math:`f_k` to evaluate corresponding x and y
data with.
xdata: a 1D float array of xdata.
ydata: a 1D float array of ydata.
p0: initial guess for optimization parameters.
sigma: Optional, a 1D array of standard deviations in ydata
in absolute units.
weights: Optional, a 1D float list of weights :math:`w_k` for each
component function :math:`f_k`.
bounds: Optional, lower and upper bounds for optimization
parameters.
kwargs: additional kwargs for scipy.optimize.curve_fit.

Returns:
result containing ``popt`` the optimal fit parameters,
``popt_err`` the standard error estimates popt,
``pcov`` the covariance matrix for the fit,
``reduced_chisq`` the reduced chi-squared parameter of fit,
``dof`` the degrees of freedom of the fit,
``xrange`` the range of xdata values used for fit.

Raises:
QiskitError: if the number of degrees of freedom of the fit is
less than 1.

.. note::
``sigma`` is assumed to be specified in the same units as ``ydata``
(absolute units). If sigma is instead specified in relative units
the `absolute_sigma=False` kwarg of scipy curve_fit must be used.
This affects the returned covariance ``pcov`` and error ``popt_err``
parameters via ``pcov(absolute_sigma=False) = pcov * reduced_chisq``
``popt_err(absolute_sigma=False) = popt_err * sqrt(reduced_chisq)``.
"""
num_funcs = len(funcs)

# Get positions for indexes data sets
series = np.asarray(series, dtype=int)
idxs = [series == i for i in range(num_funcs)]

# Combine weights and sigma for transformation
if weights is None:
wsigma = sigma
else:
wsigma = np.zeros(ydata.size)
if sigma is None:
for i in range(num_funcs):
wsigma[idxs[i]] = 1 / np.sqrt(weights[i])
else:
for i in range(num_funcs):
wsigma[idxs[i]] = sigma[idxs[i]] / np.sqrt(weights[i])

# Define multi-objective function
def f(x, *params):
y = np.zeros(x.size)
for i in range(num_funcs):
xi = x[idxs[i]]
yi = funcs[i](xi, *params)
y[idxs[i]] = yi
return y

# Run linearized curve_fit
analysis_result = curve_fit(f, xdata, ydata, p0, sigma=wsigma, bounds=bounds, **kwargs)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code of this function is difficult to follow in the lack of internal documentation. Also, in the doc string, the meaning of the series argument is not clear. The text at the beginning of the doc string implies that xdata, ydata, and sigma are expected to be 2D arrays, but then it turns out that they are 1D arrays, and the connection is not clear.

return analysis_result


def process_curve_data(
data: List[Dict[str, any]], data_processor: Callable, x_key: str = "xval", **filters
) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
"""Return tuple of arrays (x, y, sigma) data for curve fitting.

Args
data: list of circuit data dictionaries containing counts.
data_processor: callable for processing data to y, sigma
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this intended to be the same data processor as in #22 ?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I left comments on this in your PR. Ideally I would like if it could be either a DataProcessor from 22, or just a single node, or a regular function with the correct signature.

x_key: key for extracting xdata value from metadata (Default: "xval").
filters: additional kwargs to filter metadata on.

Returns:
tuple: ``(x, y, sigma)`` tuple of arrays of x-values,
y-values, and standard deviations of y-values.
"""
filtered_data = filter_data(data, **filters)
size = len(filtered_data)
xdata = np.zeros(size, dtype=float)
ydata = np.zeros(size, dtype=float)
ydata_var = np.zeros(size, dtype=float)

for i, datum in enumerate(filtered_data):
metadata = datum["metadata"]
xdata[i] = metadata[x_key]
y_mean, y_var = data_processor(datum)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The data process should be aware of whether sigma is absolute

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one is not a mistake. The computation of sigma is different for different values of absolute_sigma.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@eggerdj Note the previous comment because it's relevant to #22

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The choice of setting absolute_sigma = False/True should depend on how sigma was calculated, not the other way around. Absolute sigma is a property of curve fit function for how it calculates pcov (and hence how we deduce popt_err). According to scipy docs this is pcov(absolute_sigma=False) = pcov(absolute_sigma=True) * chisq(popt)/(M-N)

By defaulting to True we are assuming that sigma is usually specified in the same units of y, so in that sense we should make sure any data processors calculate sigma in the same units as y by default as well.

ydata[i] = y_mean
ydata_var[i] = y_var

return xdata, ydata, np.sqrt(ydata_var)


def process_multi_curve_data(
data: List[Dict[str, any]],
data_processor: Callable,
x_key: str = "xval",
series_key: str = "series",
**filters,
) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
"""Return tuple of arrays (series, x, y, sigma) data for multi curve fitting.

Args
data: list of circuit data dictionaries.
data_processor: callable for processing data to y, sigma
x_key: key for extracting xdata value from metadata (Default: "xval").
series_key: key for extracting series value from metadata (Default: "series").
filters: additional kwargs to filter metadata on.

Returns:
tuple: ``(series, x, y, sigma)`` tuple of arrays of series values,
x-values, y-values, and standard deviations of y-values.
"""
filtered_data = filter_data(data, **filters)
size = len(filtered_data)
series = np.zeros(size, dtype=int)
xdata = np.zeros(size, dtype=float)
ydata = np.zeros(size, dtype=float)
ydata_var = np.zeros(size, dtype=float)

for i, datum in enumerate(filter_data):
metadata = datum["metadata"]
series[i] = metadata[series_key]
xdata[i] = metadata[x_key]
y_mean, y_var = data_processor(datum)
ydata[i] = y_mean
ydata_var[i] = y_var

return series, xdata, ydata, np.sqrt(ydata_var)
Loading