Source code for pybop.costs.feature_distances

import warnings

import numpy as np
from scipy.optimize import minimize

from pybop.costs.base_cost import BaseCost
from pybop.costs.evaluation import Evaluation


[docs] def indices_of(values, target): roots = (np.sign(values[1:] - target) - np.sign(values[:-1] - target)).nonzero()[0] nearest_roots = np.unique( np.where( np.abs(values[roots] - target) < np.abs(values[roots + 1]), roots, roots + 1 ) ) return nearest_roots
[docs] class FeatureDistance(BaseCost): """Base for defining cost functions based on comparing fit functions.""" _supported_features = [] def __init__( self, domain_data: np.ndarray, target_data: np.ndarray, feature: str, time_start: float = None, time_end: float = None, ): super().__init__() if feature not in self._supported_features: raise ValueError( "Feature '" + feature + "' not supported. Options: " + str(self._supported_features) ) self._domain_data = domain_data self._target_data = target_data self.feature = feature self.time_start = time_start self.time_end = time_end self.start_index = ( indices_of(self.domain_data, self.time_start)[0] if self.time_start else 0 ) self.end_index = ( indices_of(self.domain_data, self.time_end)[0] if self.time_end else None ) with warnings.catch_warnings(): # Suppress SciPy's UserWarning about delta_grad == 0. warnings.simplefilter("ignore") self.data_fit = self._fit( self.domain_data[self.start_index : self.end_index], self.target_data[self.start_index : self.end_index], )
[docs] def _inverse_fit_function(self, y, *args): return NotImplementedError
[docs] def _fit_guess(self, t, y): return NotImplementedError
[docs] def _feature_selection(self, fit): return NotImplementedError
[docs] def _fit(self, t, y): """ Uses SciPy to fit data. For numerical reasons, the fitting involves applying the fit function to data and comparing to identity. """ t = t - t[0] fit_guess = self._fit_guess(t, y) return self._feature_selection( minimize( lambda x: np.sum((t - self._inverse_fit_function(y, *x)) ** 2) ** 0.5, x0=fit_guess, method="trust-constr", ).x )
[docs] def __call__( self, y: np.ndarray, dy: np.ndarray | None = None, ) -> float | tuple[float, np.ndarray]: with warnings.catch_warnings(): # Suppress SciPy's UserWarning about delta_grad == 0. warnings.simplefilter("ignore") error = np.abs( np.asarray( [ ( self._fit( self.domain_data[self.start_index : self.end_index], y[self.start_index : self.end_index], ) - self.data_fit ) / self.data_fit ] ) ) return Evaluation(error.item())
[docs] class SquareRootFeatureDistance(FeatureDistance): """ Square-root fit cost function. Fits a square-root fit function and compares either its offset or its slope between model predictions and target data. """ _supported_features = ["offset", "slope", "inverse_slope"] def __init__( self, domain_data: np.ndarray, target_data: np.ndarray, feature: str = "inverse_slope", time_start: float = None, time_end: float = None, ): """ Parameters ---------- domain_data : np.ndarray The content of the "Time [s]" entry in the used `DataSet`. feature : str, optional Set the fit parameter from the square-root fit to use for fitting. Possible values: - "offset": The value of the square-root fit at the start. - "slope": The prefactor of the square-root over time. - "inverse_slope": 1 over "slope"; may perform better. time_start : float, optional Set the time (in seconds) from which onwards the data shall be fitted, counted from the start of the data. Default is the start. time_end : float, optional Set the time (in seconds) until which the data shall be fitted, counted from the start of the data. Default is the end. """ super().__init__(domain_data, target_data, feature, time_start, time_end)
[docs] def _inverse_fit_function(self, y, b, c): """Square function to transform data for a linear fit.""" return ((y - b) / c) ** 2
[docs] def _fit_guess(self, t, y): return [y[0], (y[-1] - y[0]) / (t[-1] - t[0]) ** 0.5]
[docs] def _feature_selection(self, fit): if self.feature == "offset": return fit[0] elif self.feature == "slope": return fit[1] elif self.feature == "inverse_slope": return 1 / fit[1]
[docs] class ExponentialFeatureDistance(FeatureDistance): """ Exponential fit cost function. Fits an exponential and compares either its asymptote, its magnitude, or its timescale between model predictions and target data. """ _supported_features = ["asymptote", "magnitude", "timescale", "inverse_timescale"] def __init__( self, domain_data: np.ndarray, target_data: np.ndarray, feature: str = "inverse_timescale", time_start: float = None, time_end: float = None, ): """ Parameters ---------- domain_data : np.ndarray The content of the "Time [s]" entry in the used `DataSet`. feature : str, optional Set the fit parameter from the square-root fit to use for fitting. Possible values: - "asymptote": The exponential fit value at infinite time. - "magnitude": The prefactor of the exponential term. - "timescale": The denominator in the exponential argument. - "inverse_timescale": 1 over "timescale"; may perform better. time_start : float, optional Set the time (in seconds) from which onwards the data shall be fitted, counted from the start of the data. Default is the start. time_end : float, optional Set the time (in seconds) until which the data shall be fitted, counted from the start of the data. Default is the end. """ super().__init__(domain_data, target_data, feature, time_start, time_end)
[docs] def _inverse_fit_function(self, y, b, c, d): """Logarithm function to transform data for a linear fit.""" log_arg = (y - b) / c log_arg[log_arg <= 0] = 0.1**d return -d * np.log(log_arg)
[docs] def _fit_guess(self, t, y): constant = y[-1] difference = (y[-1] - y[0]) / (t[-1] - t[0]) second_difference = (y[-1] - 2 * y[len(y) // 2] + y[0]) / ( t[len(t) // 2] - t[0] ) ** 2 return [ constant, difference**2 / second_difference, -difference / second_difference, ]
[docs] def _feature_selection(self, fit): if self.feature == "asymptote": return fit[0] elif self.feature == "magnitude": return fit[1] elif self.feature == "timescale": return fit[2] elif self.feature == "inverse_timescale": return 1 / fit[2]